@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.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
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
2821
- async expandTeamUsers(teamId) {
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
- return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
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
- async lookupManager(userId) {
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
- return row?.manager_id ? String(row.manager_id) : null;
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
- f.$or = [
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
- async stop(ctx) {
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
- const jobs = ctx.getService("job");
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 = {