@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/dist/index.mjs CHANGED
@@ -2220,6 +2220,38 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2220
2220
  return userIds.filter((u) => canRead.has(u));
2221
2221
  }
2222
2222
 
2223
+ // src/payload-redaction.ts
2224
+ function redactSnapshot(payload, readable) {
2225
+ if (readable === void 0) return { payload, redactedKeys: [] };
2226
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
2227
+ return { payload, redactedKeys: [] };
2228
+ }
2229
+ const allowed = new Set(readable.map((f) => String(f)));
2230
+ const source = payload;
2231
+ const redactedKeys = [];
2232
+ const kept = {};
2233
+ for (const key of Object.keys(source)) {
2234
+ if (allowed.has(key)) kept[key] = source[key];
2235
+ else redactedKeys.push(key);
2236
+ }
2237
+ if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
2238
+ return { payload: kept, redactedKeys: redactedKeys.sort() };
2239
+ }
2240
+ async function resolveReadableSnapshotFields(security, objectName, context, logger) {
2241
+ if (!security || typeof security.getReadableFields !== "function") return void 0;
2242
+ const object = String(objectName ?? "").trim();
2243
+ if (!object) return void 0;
2244
+ try {
2245
+ return await security.getReadableFields(object, context);
2246
+ } catch (err) {
2247
+ logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
2248
+ object,
2249
+ error: err?.message ?? String(err)
2250
+ });
2251
+ return void 0;
2252
+ }
2253
+ }
2254
+
2223
2255
  // src/approval-service.ts
2224
2256
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
2225
2257
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
@@ -2240,6 +2272,7 @@ function actingUserId(context) {
2240
2272
  return typeof userId === "string" && userId ? userId : null;
2241
2273
  }
2242
2274
  var OOO_MAX_CHAIN = 8;
2275
+ var MEMBER_SCREEN_READ_LIMIT = 5e4;
2243
2276
  var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2244
2277
  "team",
2245
2278
  "department",
@@ -2392,6 +2425,7 @@ var _ApprovalService = class _ApprovalService {
2392
2425
  this.messaging = opts.messaging;
2393
2426
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2394
2427
  this.tenancyPosture = opts.tenancyPosture;
2428
+ this.fieldVisibility = opts.fieldVisibility;
2395
2429
  this.recordReaderVisibleObjects = new Set(
2396
2430
  (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2397
2431
  );
@@ -2400,6 +2434,57 @@ var _ApprovalService = class _ApprovalService {
2400
2434
  attachTenancyPosture(provider) {
2401
2435
  this.tenancyPosture = provider;
2402
2436
  }
2437
+ /**
2438
+ * [#10749] Attach (or replace) the field-visibility authority the payload
2439
+ * redaction seam reads. Late-bound: plugin load order does not guarantee the
2440
+ * security service exists when this one is constructed.
2441
+ */
2442
+ attachFieldVisibility(source) {
2443
+ this.fieldVisibility = source;
2444
+ }
2445
+ /**
2446
+ * [#10749] Redact each row's payload snapshot down to the fields the READING
2447
+ * caller may see on that row's subject object.
2448
+ *
2449
+ * Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
2450
+ * load-bearing rather than incidental: `enrichRows` derives `payload_display`
2451
+ * (lookup foreign keys inside the snapshot resolved to referenced record
2452
+ * titles) and `payload_labels` (a label per snapshot key) by WALKING THE
2453
+ * SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
2454
+ * redact after and a restricted field's name, its authored label and the
2455
+ * title of the record it points at all still ship — the value would be gone
2456
+ * and the disclosure would not.
2457
+ *
2458
+ * Rows are grouped by subject object so one `getReadableFields` call covers a
2459
+ * whole page of same-object requests.
2460
+ */
2461
+ async redactPayloads(rows, context) {
2462
+ const withPayload = rows.filter((r) => r?.payload != null);
2463
+ if (withPayload.length === 0) return;
2464
+ const byObject = /* @__PURE__ */ new Map();
2465
+ for (const r of withPayload) {
2466
+ const key = String(r.object_name ?? "");
2467
+ let list = byObject.get(key);
2468
+ if (!list) {
2469
+ list = [];
2470
+ byObject.set(key, list);
2471
+ }
2472
+ list.push(r);
2473
+ }
2474
+ for (const [object, group] of byObject) {
2475
+ const readable = await resolveReadableSnapshotFields(
2476
+ this.fieldVisibility,
2477
+ object,
2478
+ context,
2479
+ this.logger
2480
+ );
2481
+ if (readable === void 0) continue;
2482
+ for (const r of group) {
2483
+ const { payload } = redactSnapshot(r.payload, readable);
2484
+ r.payload = payload;
2485
+ }
2486
+ }
2487
+ }
2403
2488
  /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2404
2489
  get orgScopeDeps() {
2405
2490
  return {
@@ -2666,7 +2751,7 @@ var _ApprovalService = class _ApprovalService {
2666
2751
  }) : users;
2667
2752
  try {
2668
2753
  if (type === "team") {
2669
- const users = await this.expandTeamUsers(String(a.value));
2754
+ const users = await this.expandTeamUsers(String(a.value), organizationId);
2670
2755
  if (users.length) return users;
2671
2756
  } else if (type === "department" || type === "business_unit" || type === "bu") {
2672
2757
  const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
@@ -2680,7 +2765,7 @@ var _ApprovalService = class _ApprovalService {
2680
2765
  } else if (type === "manager" && record) {
2681
2766
  const subject = record[a.value] ?? record.owner_id;
2682
2767
  if (subject) {
2683
- const mgr = await this.lookupManager(String(subject));
2768
+ const mgr = await this.lookupManager(String(subject), organizationId);
2684
2769
  if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
2685
2770
  }
2686
2771
  }
@@ -2777,7 +2862,7 @@ var _ApprovalService = class _ApprovalService {
2777
2862
  try {
2778
2863
  if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2779
2864
  else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2780
- else if (resolveAs === "team") users = await this.expandTeamUsers(key);
2865
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
2781
2866
  else {
2782
2867
  throw new Error(
2783
2868
  `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
@@ -2802,9 +2887,50 @@ var _ApprovalService = class _ApprovalService {
2802
2887
  }
2803
2888
  return { slots, raw };
2804
2889
  }
2805
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
2806
- async expandTeamUsers(teamId) {
2890
+ /**
2891
+ * Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
2892
+ *
2893
+ * Takes an organization for the reason every sibling expansion does
2894
+ * ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
2895
+ * {@link expandMembershipTierUsers}): an approver expansion answers "who, in
2896
+ * THIS organization". Before #10230 this one did not ask, and it was the last
2897
+ * expansion that did not — a `team` approver naming ANOTHER organization's
2898
+ * team routed that organization's people an approval over a record they are
2899
+ * not a tenant of.
2900
+ *
2901
+ * TWO screens run here, and they assert different things (#10230, #10547):
2902
+ *
2903
+ * 1. the TEAM must not provably belong to another organization
2904
+ * ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
2905
+ * `organization_id` outright
2906
+ * (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
2907
+ * team id transitively names exactly one organization and ONE row
2908
+ * answers the question;
2909
+ * 2. each expanded MEMBER must not provably hold membership only in other
2910
+ * organizations ({@link dropMembersProvablyOutsideOrg}) —
2911
+ * `sys_team_member` carries `team_id` and `user_id` and NO tenancy
2912
+ * column at all, so passing (1) says nothing whatever about the people
2913
+ * it lists.
2914
+ *
2915
+ * #10230 landed (1) alone and deferred (2) on purpose. What closed the
2916
+ * deferral is that (1) does not imply (2) even a little: a member removed
2917
+ * from the organization but left on the team, a team re-parented across
2918
+ * organizations (`/organization/update-team` accepts `organizationId` in its
2919
+ * partial body), or a `sys_team_member` row written by a seed rather than
2920
+ * through better-auth all produce a team that passes (1) carrying a user who
2921
+ * is provably a tenant of somewhere else. Measured on this tree, not read off
2922
+ * the schema — the probe is quoted in `team-member-org-screen.test.ts`.
2923
+ *
2924
+ * (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
2925
+ * further out, and it is asserted the same way: `sys_user` carries no tenancy
2926
+ * fact, so `sys_member` rows are the only evidence that a person is placed
2927
+ * anywhere. Like that screen, this one grants no reads and applies no read
2928
+ * screen to any approver type that lacks one today, so it decides nothing
2929
+ * #7497 (does approver routing imply record read visibility?) asks.
2930
+ */
2931
+ async expandTeamUsers(teamId, organizationId) {
2807
2932
  if (!teamId) return [];
2933
+ if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
2808
2934
  let rows = [];
2809
2935
  try {
2810
2936
  rows = await this.engine.find("sys_team_member", {
@@ -2816,7 +2942,149 @@ var _ApprovalService = class _ApprovalService {
2816
2942
  } catch {
2817
2943
  rows = [];
2818
2944
  }
2819
- return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2945
+ const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2946
+ return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
2947
+ }
2948
+ /**
2949
+ * Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
2950
+ *
2951
+ * "Provably" carries the same posture the sibling screen states at length in
2952
+ * {@link managerIsProvablyOutsideOrg}, for the same reasons:
2953
+ *
2954
+ * - the team row carries an `organization_id` and it is not the request's
2955
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
2956
+ * - the row carries no `organization_id`, does not exist, or the read failed
2957
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
2958
+ *
2959
+ * The `organization_id = null` limb is not timidity — it is the reading
2960
+ * {@link businessUnitOrgScope} settled on one screen below, for the identical
2961
+ * shape: null on a platform object means "owned by no organization", which is
2962
+ * what a seed writes because a seed cannot know the organization id the
2963
+ * runtime mints at boot. Treating null as "not mine" would delete every
2964
+ * seeded team approver at once — a larger behaviour change than the hole
2965
+ * being closed. Measured, and not hypothetically: this package's own
2966
+ * `team_ok` expansion fixture is exactly such a stack (it has
2967
+ * `sys_team_member` rows, a request carrying an organization, and no
2968
+ * `sys_team` row at all).
2969
+ *
2970
+ * Screening the TEAM before reading its members is also what keeps the cost
2971
+ * at one row: a team that fails the screen never fans out.
2972
+ */
2973
+ async teamIsProvablyOutsideOrg(teamId, organizationId) {
2974
+ const requestOrg = organizationId ? String(organizationId) : "";
2975
+ if (!requestOrg) return false;
2976
+ let rows = [];
2977
+ try {
2978
+ rows = await this.engine.find("sys_team", {
2979
+ where: { id: teamId },
2980
+ fields: ["id", "organization_id"],
2981
+ limit: 1,
2982
+ context: SYSTEM_CTX2
2983
+ });
2984
+ } catch {
2985
+ return false;
2986
+ }
2987
+ const row = Array.isArray(rows) ? rows[0] : null;
2988
+ const teamOrg = row?.organization_id ? String(row.organization_id) : "";
2989
+ if (!teamOrg) return false;
2990
+ if (teamOrg === requestOrg) return false;
2991
+ this.logger?.warn?.(
2992
+ `[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.`,
2993
+ { teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
2994
+ );
2995
+ return true;
2996
+ }
2997
+ /**
2998
+ * Drop the expanded team members who are PROVABLY tenants of other
2999
+ * organizations and not of `organizationId`. (#10547)
3000
+ *
3001
+ * Returns the survivors, in the order they were expanded.
3002
+ *
3003
+ * Posture — identical to {@link managerIsProvablyOutsideOrg} and
3004
+ * {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
3005
+ * assertion about the same table:
3006
+ *
3007
+ * - membership rows exist for this user, none in `organizationId`
3008
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
3009
+ * - no membership rows at all for him, the read failed, or the request
3010
+ * carries no organization
3011
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3012
+ *
3013
+ * The absent limb is load-bearing rather than timid, and #3807 is the recorded
3014
+ * cost of getting it wrong: a stack that stamps an organization on requests
3015
+ * but never materializes `sys_member` rows would otherwise lose EVERY team
3016
+ * approver at once. This package's own `team_ok` expansion fixture and
3017
+ * #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
3018
+ * a request organization and no `sys_member` table at all — so the absent
3019
+ * limb is exercised by neighbours on every run of this suite.
3020
+ *
3021
+ * ONE read for the whole slate, never one per person: the expansion is capped
3022
+ * at 10000 members and a per-user query would turn a single team approver
3023
+ * into 10000 round trips.
3024
+ *
3025
+ * ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
3026
+ * the only evidence that a member IS a tenant here, so a result cut off at
3027
+ * the limit could be missing the very row that keeps a legitimate approver on
3028
+ * the slate — screening him out on missing evidence, which inverts the
3029
+ * posture into fail-CLOSED precisely where it must not. When the read comes
3030
+ * back at the cap it is treated as no evidence at all.
3031
+ */
3032
+ async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
3033
+ const requestOrg = organizationId ? String(organizationId) : "";
3034
+ if (!requestOrg || !userIds.length) return userIds;
3035
+ let rows = [];
3036
+ try {
3037
+ rows = await this.engine.find("sys_member", {
3038
+ where: { user_id: { $in: userIds } },
3039
+ fields: ["user_id", "organization_id"],
3040
+ limit: MEMBER_SCREEN_READ_LIMIT,
3041
+ context: SYSTEM_CTX2
3042
+ });
3043
+ } catch {
3044
+ return userIds;
3045
+ }
3046
+ if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
3047
+ this.logger?.warn?.(
3048
+ `[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.`,
3049
+ { teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
3050
+ );
3051
+ return userIds;
3052
+ }
3053
+ const orgsByUser = /* @__PURE__ */ new Map();
3054
+ for (const r of rows ?? []) {
3055
+ const uid2 = String(r?.user_id ?? "");
3056
+ const org = String(r?.organization_id ?? "");
3057
+ if (!uid2 || !org) continue;
3058
+ const seen = orgsByUser.get(uid2);
3059
+ if (seen) seen.push(org);
3060
+ else orgsByUser.set(uid2, [org]);
3061
+ }
3062
+ const kept = [];
3063
+ const dropped = [];
3064
+ for (const uid2 of userIds) {
3065
+ const orgs = orgsByUser.get(uid2);
3066
+ if (!orgs?.length) {
3067
+ kept.push(uid2);
3068
+ continue;
3069
+ }
3070
+ if (orgs.includes(requestOrg)) {
3071
+ kept.push(uid2);
3072
+ continue;
3073
+ }
3074
+ dropped.push({ userId: uid2, organizationIds: orgs });
3075
+ }
3076
+ if (dropped.length) {
3077
+ this.logger?.warn?.(
3078
+ `[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.`,
3079
+ {
3080
+ teamId,
3081
+ requestOrganizationId: requestOrg,
3082
+ droppedUserIds: dropped.map((d) => d.userId),
3083
+ droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
3084
+ }
3085
+ );
3086
+ }
3087
+ return kept;
2820
3088
  }
2821
3089
  /**
2822
3090
  * Tenant scope for a `sys_business_unit` read that may legitimately be
@@ -2985,7 +3253,36 @@ var _ApprovalService = class _ApprovalService {
2985
3253
  }
2986
3254
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2987
3255
  }
2988
- async lookupManager(userId) {
3256
+ /**
3257
+ * `sys_user.manager_id`, screened to the request's organization (#10153).
3258
+ *
3259
+ * Takes an organization argument for the same reason its siblings do
3260
+ * ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
3261
+ * approver expansion answers "who, in THIS organization". Before #10153 this
3262
+ * one did not ask, and it was the only expansion that did not — a
3263
+ * `manager_id` pointing at a person in another organization routed that
3264
+ * person an approval over a record they are not a tenant of.
3265
+ *
3266
+ * ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
3267
+ * filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
3268
+ * comment exists so the next reader does not conclude that #7497 (does
3269
+ * approver routing imply record read visibility?) was settled here. It was
3270
+ * not. Two facts make this the SIBLING treatment rather than a
3271
+ * read-visibility ruling:
3272
+ *
3273
+ * 1. Two of the three org-scoped expansions already screen on exactly this
3274
+ * column — `expandMembershipTierUsers` filters `sys_member.organization_id`
3275
+ * outright, and it is also the second limb of `expandPositionUsers`. So
3276
+ * `sys_member.organization_id` is already this file's answer to "which
3277
+ * organization is this person in", independent of what they may read.
3278
+ * 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
3279
+ * table, so a membership row is the only tenancy fact that exists for a
3280
+ * user — there is no other read this screen could have been written with.
3281
+ *
3282
+ * This change grants no reads and applies no read screen to any type that
3283
+ * lacks one today, so it decides nothing #7497 asks.
3284
+ */
3285
+ async lookupManager(userId, organizationId) {
2989
3286
  try {
2990
3287
  const rows = await this.engine.find("sys_user", {
2991
3288
  where: { id: userId },
@@ -2994,11 +3291,64 @@ var _ApprovalService = class _ApprovalService {
2994
3291
  context: SYSTEM_CTX2
2995
3292
  });
2996
3293
  const row = Array.isArray(rows) ? rows[0] : null;
2997
- return row?.manager_id ? String(row.manager_id) : null;
3294
+ const managerId = row?.manager_id ? String(row.manager_id) : null;
3295
+ if (!managerId) return null;
3296
+ if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
3297
+ return managerId;
2998
3298
  } catch {
2999
3299
  return null;
3000
3300
  }
3001
3301
  }
3302
+ /**
3303
+ * Is `managerId` PROVABLY a member of other organizations and not of
3304
+ * `organizationId`? (#10153)
3305
+ *
3306
+ * "Provably" is the whole shape of this screen, and it is deliberate rather
3307
+ * than a weaker version of "must prove membership":
3308
+ *
3309
+ * - membership rows exist for this user, none in the request's org
3310
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
3311
+ * - no membership rows at all, or the read failed
3312
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3313
+ *
3314
+ * The fail-open half is not timidity, it is this file's ruled posture on
3315
+ * addressing paths, stated twice already: {@link filterApproversWhoCanRead}
3316
+ * refuses to empty a live slate on an infrastructure hiccup, and
3317
+ * {@link expandPositionUsers} carries "a step routing to nobody is worse than
3318
+ * one routing to a lapsed holder". It is also load-bearing in practice — a
3319
+ * stack that stamps an organization on its requests but does not materialize
3320
+ * `sys_member` rows would otherwise lose every manager approver at once,
3321
+ * which is a bigger behaviour change than the hole being closed. Measured:
3322
+ * this repo's own `type:manager` out-of-office fixture is such a stack.
3323
+ *
3324
+ * Screening the MANAGER only, before OOO delegation, is deliberate too: the
3325
+ * delegate arrives from `sys_approval_delegation`, whose rows already carry
3326
+ * (and are already filtered by) an `organization_id` in
3327
+ * {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
3328
+ */
3329
+ async managerIsProvablyOutsideOrg(managerId, organizationId) {
3330
+ const requestOrg = organizationId ? String(organizationId) : "";
3331
+ if (!requestOrg) return false;
3332
+ let rows = [];
3333
+ try {
3334
+ rows = await this.engine.find("sys_member", {
3335
+ where: { user_id: managerId },
3336
+ fields: ["user_id", "organization_id"],
3337
+ limit: 1e3,
3338
+ context: SYSTEM_CTX2
3339
+ });
3340
+ } catch {
3341
+ return false;
3342
+ }
3343
+ const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
3344
+ if (!orgs.length) return false;
3345
+ if (orgs.includes(requestOrg)) return false;
3346
+ this.logger?.warn?.(
3347
+ `[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.`,
3348
+ { managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
3349
+ );
3350
+ return true;
3351
+ }
3002
3352
  /**
3003
3353
  * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
3004
3354
  * id, follow any active `sys_approval_delegation` chain and return the id the
@@ -5124,8 +5474,70 @@ var _ApprovalService = class _ApprovalService {
5124
5474
  return { requests: desired.size, inserted, deleted };
5125
5475
  }
5126
5476
  // ── Read API ─────────────────────────────────────────────────
5477
+ /**
5478
+ * [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
5479
+ * for THIS caller over THIS query's scope?
5480
+ *
5481
+ * `payload_json` is the one searched column whose contents the serve path
5482
+ * masks per reader (`redactPayloads`, #10749). A predicate over it is
5483
+ * evaluated by the driver against the column AT REST — unmasked, before
5484
+ * anything is served — so for a caller whose view of the snapshot is masked,
5485
+ * row membership answers questions about contents that caller may not read.
5486
+ * The other four arms are columns of `sys_approval_request` itself, which
5487
+ * every caller who can see the row reads whole; they are untouched.
5488
+ *
5489
+ * ## The invariant this method exists to hold
5490
+ *
5491
+ * "This caller's view is masked" is read from **the same authority and the
5492
+ * same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
5493
+ * asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
5494
+ * notion of "redacted" — comparing the readable set against the object's
5495
+ * schema, say — would be a fresh source of drift, and drift between the
5496
+ * serve rule and the filter rule IS the defect this closes, reconstituted one
5497
+ * layer down. So the only "not masked" answer accepted here is the one serve
5498
+ * itself acts on: `undefined`, the seam's documented do-not-narrow branch.
5499
+ * When the seam holds a concrete list the mask is IN FORCE, whether or not it
5500
+ * happens to remove a key from any particular row — a row-dependent question
5501
+ * no predicate can answer before rows exist.
5502
+ *
5503
+ * ## The two predicate-time cases
5504
+ *
5505
+ * Redaction is decided per ROW (each row names its own subject object, hence
5506
+ * its own readable set), but a filter is built before any row exists:
5507
+ *
5508
+ * - **authority absent** — `resolveReadableSnapshotFields` answers
5509
+ * `undefined` for EVERY object, so serve hands over every snapshot whole.
5510
+ * Keeping the arm leaks nothing serve does not already hand over, and this
5511
+ * is the shape every deployment that has not wired the security plugin
5512
+ * gets: search is byte-for-byte unchanged. Checked first, and that order
5513
+ * is load-bearing — see the object-scope note below.
5514
+ * - **authority wired** — the readable set is per object, so the arm is
5515
+ * admissible only for a scope of exactly one KNOWN object. With
5516
+ * `filter.object` present that object is known at predicate time and the
5517
+ * seam is asked about it directly. Absent, the query spans every object
5518
+ * and there is nothing sound to ask, so the arm is dropped.
5519
+ *
5520
+ * Dropping an arm is strictly NARROWING: it never refuses a query and never
5521
+ * widens what comes back, so the fail-closed direction is cheap here and is
5522
+ * taken rather than reaching for a per-object predicate machine to avoid it.
5523
+ * Refusing the query outright would be the louder behaviour change, and is
5524
+ * deliberately not what this does.
5525
+ */
5526
+ async freeTextMayMatchSnapshot(objectInScope, context) {
5527
+ const authority = this.fieldVisibility;
5528
+ if (!authority || typeof authority.getReadableFields !== "function") return true;
5529
+ const object = String(objectInScope ?? "").trim();
5530
+ if (!object) return false;
5531
+ const readable = await resolveReadableSnapshotFields(
5532
+ authority,
5533
+ object,
5534
+ context,
5535
+ this.logger
5536
+ );
5537
+ return readable === void 0;
5538
+ }
5127
5539
  /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
5128
- buildRequestWhere(filter, context) {
5540
+ async buildRequestWhere(filter, context) {
5129
5541
  const f = {};
5130
5542
  if (filter?.object) f.object_name = filter.object;
5131
5543
  if (filter?.recordId) f.record_id = filter.recordId;
@@ -5134,13 +5546,16 @@ var _ApprovalService = class _ApprovalService {
5134
5546
  if (tenantOrg) f.organization_id = tenantOrg;
5135
5547
  const q = filter?.q?.trim();
5136
5548
  if (q) {
5137
- f.$or = [
5549
+ const arms = [
5138
5550
  { process_name: { $contains: q } },
5139
5551
  { object_name: { $contains: q } },
5140
5552
  { record_id: { $contains: q } },
5141
- { submitter_id: { $contains: q } },
5142
- { payload_json: { $contains: q } }
5553
+ { submitter_id: { $contains: q } }
5143
5554
  ];
5555
+ if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
5556
+ arms.push({ payload_json: { $contains: q } });
5557
+ }
5558
+ f.$or = arms;
5144
5559
  }
5145
5560
  if (Array.isArray(filter?.status)) {
5146
5561
  const statuses = filter.status.filter(Boolean);
@@ -5343,7 +5758,7 @@ var _ApprovalService = class _ApprovalService {
5343
5758
  return true;
5344
5759
  }
5345
5760
  async listRequests(filter, context) {
5346
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
5761
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5347
5762
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5348
5763
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5349
5764
  if (ids) {
@@ -5367,12 +5782,13 @@ var _ApprovalService = class _ApprovalService {
5367
5782
  }
5368
5783
  const rows = await this.engine.find("sys_approval_request", findOpts);
5369
5784
  const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
5785
+ await this.redactPayloads(list, context);
5370
5786
  await this.enrichRows(list);
5371
5787
  this.attachViewers(list, context);
5372
5788
  return list;
5373
5789
  }
5374
5790
  async countRequests(filter, context) {
5375
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
5791
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5376
5792
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5377
5793
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5378
5794
  if (ids) {
@@ -5435,6 +5851,7 @@ var _ApprovalService = class _ApprovalService {
5435
5851
  if (visible && !visible.has(String(rows[0].id))) return null;
5436
5852
  }
5437
5853
  const row = rowFromRequest(rows[0]);
5854
+ await this.redactPayloads([row], context);
5438
5855
  await this.enrichRows([row]);
5439
5856
  await this.attachFlowSteps(row);
5440
5857
  await this.attachDecisionProgress(row, rows[0]);
@@ -5938,6 +6355,52 @@ function unbindAllHooks(engine) {
5938
6355
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
5939
6356
  }
5940
6357
 
6358
+ // src/payload-redaction-middleware.ts
6359
+ var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
6360
+ function parseSnapshot(raw) {
6361
+ if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
6362
+ try {
6363
+ return { ok: true, value: JSON.parse(raw) };
6364
+ } catch {
6365
+ return { ok: false, value: void 0 };
6366
+ }
6367
+ }
6368
+ async function redactRowsInPlace(rows, security, context, logger) {
6369
+ const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
6370
+ if (list.length === 0) return;
6371
+ const cache = /* @__PURE__ */ new Map();
6372
+ for (const row of list) {
6373
+ if (!row || typeof row !== "object") continue;
6374
+ const raw = row.payload_json;
6375
+ const parsed = parseSnapshot(raw);
6376
+ if (!parsed.ok) continue;
6377
+ const object = String(row.object_name ?? "").trim();
6378
+ if (!object) continue;
6379
+ if (!cache.has(object)) {
6380
+ cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
6381
+ }
6382
+ const readable = cache.get(object);
6383
+ if (readable === void 0) continue;
6384
+ const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
6385
+ if (redactedKeys.length === 0) continue;
6386
+ row.payload_json = JSON.stringify(payload);
6387
+ }
6388
+ }
6389
+ function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
6390
+ engine.registerMiddleware(async (opCtx, next) => {
6391
+ await next();
6392
+ if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
6393
+ if (opCtx?.context?.isSystem) return;
6394
+ try {
6395
+ await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
6396
+ } catch (err) {
6397
+ logger?.warn?.("[approvals] snapshot redaction middleware failed", {
6398
+ error: err?.message ?? String(err)
6399
+ });
6400
+ }
6401
+ }, { object: APPROVAL_REQUEST_OBJECT });
6402
+ }
6403
+
5941
6404
  // src/approval-node.ts
5942
6405
  import {
5943
6406
  defineActionDescriptor as defineActionDescriptor2,
@@ -6209,11 +6672,26 @@ var ApprovalsServicePlugin = class {
6209
6672
  }
6210
6673
  }
6211
6674
  });
6675
+ const fieldVisibility = () => {
6676
+ try {
6677
+ const sec = ctx.getService("security");
6678
+ return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
6679
+ } catch {
6680
+ return void 0;
6681
+ }
6682
+ };
6683
+ this.service.attachFieldVisibility({
6684
+ getReadableFields: (object, context) => {
6685
+ const sec = fieldVisibility();
6686
+ return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
6687
+ }
6688
+ });
6212
6689
  if (!this.options.disableAutoHooks) {
6213
6690
  try {
6214
6691
  unbindAllHooks(engine);
6215
6692
  bindApprovalLockHook(engine, ctx.logger);
6216
6693
  bindDelegationWriteGuard(engine, ctx.logger);
6694
+ bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
6217
6695
  } catch (err) {
6218
6696
  ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
6219
6697
  }
@@ -6253,6 +6731,7 @@ var ApprovalsServicePlugin = class {
6253
6731
  }
6254
6732
  };
6255
6733
  await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
6734
+ this.jobService = jobs;
6256
6735
  this.escalationJobScheduled = true;
6257
6736
  void sweep().catch((err) => {
6258
6737
  ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
@@ -6339,14 +6818,32 @@ var ApprovalsServicePlugin = class {
6339
6818
  );
6340
6819
  }
6341
6820
  }
6342
- async stop(ctx) {
6821
+ /**
6822
+ * The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
6823
+ * ONLY teardown entry point `ObjectKernel.performShutdown()` and
6824
+ * `LiteKernel.destroy()` invoke.
6825
+ *
6826
+ * [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
6827
+ * `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
6828
+ * past this plugin at shutdown: the SLA escalation job stayed scheduled and
6829
+ * this plugin's ObjectQL hooks stayed bound to an engine the kernel had
6830
+ * finished with. `start()` IS on the interface, so the pair read as symmetric
6831
+ * in review — that asymmetry is what let the same shape survive in six
6832
+ * packages at once.
6833
+ *
6834
+ * This member owns no timer of its own (the escalation clock belongs to
6835
+ * `service-job`), so it never cost a merge-queue eviction the way the
6836
+ * `plugin-reports` / `service-messaging` members did (#9371). The class is
6837
+ * the same one either way: a teardown the kernel does not reach.
6838
+ */
6839
+ async destroy() {
6343
6840
  if (this.escalationJobScheduled) {
6344
6841
  try {
6345
- const jobs = ctx.getService("job");
6346
- await jobs?.cancel?.(ESCALATION_JOB_NAME);
6842
+ await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
6347
6843
  } catch {
6348
6844
  }
6349
6845
  this.escalationJobScheduled = false;
6846
+ this.jobService = void 0;
6350
6847
  }
6351
6848
  if (this.engine) {
6352
6849
  try {
@@ -6355,6 +6852,17 @@ var ApprovalsServicePlugin = class {
6355
6852
  }
6356
6853
  }
6357
6854
  }
6855
+ /**
6856
+ * Retained alias for {@link destroy}. Kept because it is public API of an
6857
+ * exported class, and removing it would break an embedder who learned to call
6858
+ * it directly precisely BECAUSE the kernel never did. The parameter is now
6859
+ * optional and ignored: `destroy()` takes no context, so teardown uses the
6860
+ * job service captured when the escalation clock was wired. Prefer kernel
6861
+ * shutdown; direct callers keep working unchanged.
6862
+ */
6863
+ async stop(_ctx) {
6864
+ await this.destroy();
6865
+ }
6358
6866
  };
6359
6867
  export {
6360
6868
  APPROVAL_REVISE_CORRELATION_PREFIX,