@objectstack/plugin-approvals 17.0.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
@@ -1411,10 +1411,45 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1411
1411
  },
1412
1412
  fields: {
1413
1413
  id: import_data.Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
1414
+ // ⚠️ MEASURED DEFECT, cloud#1395 — read this before trusting the column.
1415
+ //
1416
+ // An approval request DOES belong to an organization: it is read through the
1417
+ // organization wall by the approvals inbox, and on a shared-database
1418
+ // deployment a row carrying no organization is not filtered BY that wall —
1419
+ // it is either invisible to everyone or visible to everyone, decided by
1420
+ // whatever filter each surface happens to apply rather than by the data.
1421
+ //
1422
+ // The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
1423
+ // `ctxOrg`), so it is NULL whenever the flow that opened the request ran
1424
+ // without one — every schedule / time-relative / api triggered run, none of
1425
+ // which sets a tenant. On a walled single-database HotCRM SaaS boot this
1426
+ // measured 27 of 27 rows org-less, each naming an `object_name` /
1427
+ // `record_id` owned by a specific customer.
1428
+ //
1429
+ // ⛔ Do NOT read that as "platform tables do not carry an organization".
1430
+ // `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
1431
+ // because its writer takes the organization from the RECORD the row is
1432
+ // about, with the session only as fallback (plugin-audit
1433
+ // `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
1434
+ // writers read the actor; a third reads the subject. That disagreement is
1435
+ // the defect.
1436
+ //
1437
+ // Which of the two a side-table row should follow is an open contract
1438
+ // question on cloud#1395 — the audit resolver is scope-pinned to audit
1439
+ // stamping by the #8778 ruling, so this writer needs its own. The same
1440
+ // `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
1441
+ // so all three move together.
1414
1442
  organization_id: import_data.Field.lookup("sys_organization", {
1415
1443
  label: "Organization",
1416
1444
  required: false,
1417
1445
  group: "System",
1446
+ // ⛔ String unchanged on purpose: it is extracted into the generated i18n
1447
+ // bundles (`translations/*.objects.generated.ts`, as `help`), so rewording
1448
+ // it is a translation-regeneration change and not a comment. The
1449
+ // correction it needs — it claims a propagation that measurably does not
1450
+ // happen, and says "Tenant" where ADR-0120 §Terminology requires
1451
+ // "organization" — rides the cloud#1395 write-side fix, which rewrites the
1452
+ // sentence and regenerates the four locales in one pass.
1418
1453
  description: "Tenant that owns this approval request (propagated from submitter context)"
1419
1454
  }),
1420
1455
  process_name: import_data.Field.text({
@@ -1558,6 +1593,28 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1558
1593
  // approving, rejecting, or reassigning it to a real approver. `viewer` is
1559
1594
  // attached by getRequest/listRequests; where it is absent the predicate fails
1560
1595
  // closed.
1596
+ //
1597
+ // Every predicate below is guarded for the SPARSE action face (#8990). This
1598
+ // binding is a list row or a record read carrying only what the caller
1599
+ // projected, and CEL aborts the whole expression at key resolution — so the
1600
+ // unguarded `record.viewer.can_act` faulted (`No such key: viewer`) on any
1601
+ // row without the block, and the button silently vanished, indistinguishable
1602
+ // from "the gate said no". `materializeDeclaredFields`'s doc comment in
1603
+ // `@objectstack/objectql` is the canonical statement of the guard rule; this
1604
+ // file follows it and does not restate it.
1605
+ //
1606
+ // `viewer` is a NESTED block, which needs one measurement the canonical rule
1607
+ // does not spell out. Measured against the `@objectstack/formula` CEL engine:
1608
+ // `has(record.viewer) && record.viewer != null && record.viewer.can_act`
1609
+ // still FAULTS on `{viewer: {}}` (`No such key: can_act`) and on
1610
+ // `{viewer: {can_act: null}}` (`Logical operator requires bool operands`).
1611
+ // Guarding the LEAF instead — `has(record.viewer) &&
1612
+ // has(record.viewer.can_act) && record.viewer.can_act == true` — is total
1613
+ // over every binding AND subsumes the parent `!= null` half, because `has()`
1614
+ // on a path whose parent is null answers `false` rather than faulting. So the
1615
+ // leaf `has()` plus the `== true` comparison is the MINIMAL safe form here,
1616
+ // not a longer one: `== true` is load-bearing (a bare truthy read of a null
1617
+ // leaf faults the logical operator), the parent `!= null` is not.
1561
1618
  actions: [
1562
1619
  {
1563
1620
  name: "approval_approve",
@@ -1577,7 +1634,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1577
1634
  // string[]`; the decision route persists them on `sys_approval_action`.
1578
1635
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1579
1636
  ],
1580
- visible: "record.viewer.can_act || record.viewer.can_override",
1637
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1581
1638
  locations: ["record_section", "list_item"],
1582
1639
  successMessage: "Approved.",
1583
1640
  refreshAfter: true
@@ -1605,7 +1662,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1605
1662
  { name: "comment", label: "Comment", type: "textarea", required: false },
1606
1663
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1607
1664
  ],
1608
- visible: "record.viewer.can_act || record.viewer.can_override",
1665
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1609
1666
  locations: ["record_section", "list_item"],
1610
1667
  successMessage: "Rejected.",
1611
1668
  refreshAfter: true
@@ -1626,7 +1683,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1626
1683
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1627
1684
  { name: "comment", label: "Comment", type: "textarea", required: false }
1628
1685
  ],
1629
- visible: "record.viewer.can_act || record.viewer.can_override",
1686
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true",
1630
1687
  locations: ["record_section"],
1631
1688
  successMessage: "Reassigned.",
1632
1689
  refreshAfter: true
@@ -1645,7 +1702,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1645
1702
  params: [
1646
1703
  { name: "comment", label: "Reason", type: "textarea", required: false }
1647
1704
  ],
1648
- visible: "record.viewer.can_act",
1705
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1649
1706
  locations: ["record_section"],
1650
1707
  successMessage: "Sent back for revision.",
1651
1708
  refreshAfter: true
@@ -1660,7 +1717,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1660
1717
  params: [
1661
1718
  { name: "comment", label: "What do you need?", type: "textarea", required: true }
1662
1719
  ],
1663
- visible: "record.viewer.can_act",
1720
+ visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
1664
1721
  locations: ["record_section"],
1665
1722
  successMessage: "Information requested.",
1666
1723
  refreshAfter: true
@@ -1681,7 +1738,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1681
1738
  params: [
1682
1739
  { name: "comment", label: "Note", type: "textarea", required: false }
1683
1740
  ],
1684
- visible: 'record.status == "pending" && record.viewer.is_submitter',
1741
+ visible: 'has(record.status) && record.status == "pending" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1685
1742
  locations: ["record_section"],
1686
1743
  successMessage: "Reminder sent.",
1687
1744
  refreshAfter: true
@@ -1701,7 +1758,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1701
1758
  ],
1702
1759
  // Recall applies while the request is live for the submitter — pending
1703
1760
  // (withdraw) or returned (abandon the revision instead of resubmitting).
1704
- visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
1761
+ visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1705
1762
  locations: ["record_section"],
1706
1763
  successMessage: "Recalled.",
1707
1764
  refreshAfter: true
@@ -1716,7 +1773,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1716
1773
  params: [
1717
1774
  { name: "comment", label: "What changed?", type: "textarea", required: false }
1718
1775
  ],
1719
- visible: 'record.status == "returned" && record.viewer.is_submitter',
1776
+ visible: 'has(record.status) && record.status == "returned" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1720
1777
  locations: ["record_section"],
1721
1778
  successMessage: "Resubmitted.",
1722
1779
  refreshAfter: true
@@ -2178,6 +2235,38 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2178
2235
  return userIds.filter((u) => canRead.has(u));
2179
2236
  }
2180
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
+
2181
2270
  // src/approval-service.ts
2182
2271
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
2183
2272
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
@@ -2198,6 +2287,7 @@ function actingUserId(context) {
2198
2287
  return typeof userId === "string" && userId ? userId : null;
2199
2288
  }
2200
2289
  var OOO_MAX_CHAIN = 8;
2290
+ var MEMBER_SCREEN_READ_LIMIT = 5e4;
2201
2291
  var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2202
2292
  "team",
2203
2293
  "department",
@@ -2350,11 +2440,66 @@ var _ApprovalService = class _ApprovalService {
2350
2440
  this.messaging = opts.messaging;
2351
2441
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2352
2442
  this.tenancyPosture = opts.tenancyPosture;
2443
+ this.fieldVisibility = opts.fieldVisibility;
2444
+ this.recordReaderVisibleObjects = new Set(
2445
+ (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2446
+ );
2353
2447
  }
2354
2448
  /** Attach (or replace) the ADR-0105 D9 posture provider. */
2355
2449
  attachTenancyPosture(provider) {
2356
2450
  this.tenancyPosture = provider;
2357
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
+ }
2358
2503
  /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2359
2504
  get orgScopeDeps() {
2360
2505
  return {
@@ -2621,7 +2766,7 @@ var _ApprovalService = class _ApprovalService {
2621
2766
  }) : users;
2622
2767
  try {
2623
2768
  if (type === "team") {
2624
- const users = await this.expandTeamUsers(String(a.value));
2769
+ const users = await this.expandTeamUsers(String(a.value), organizationId);
2625
2770
  if (users.length) return users;
2626
2771
  } else if (type === "department" || type === "business_unit" || type === "bu") {
2627
2772
  const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
@@ -2635,7 +2780,7 @@ var _ApprovalService = class _ApprovalService {
2635
2780
  } else if (type === "manager" && record) {
2636
2781
  const subject = record[a.value] ?? record.owner_id;
2637
2782
  if (subject) {
2638
- const mgr = await this.lookupManager(String(subject));
2783
+ const mgr = await this.lookupManager(String(subject), organizationId);
2639
2784
  if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
2640
2785
  }
2641
2786
  }
@@ -2732,7 +2877,7 @@ var _ApprovalService = class _ApprovalService {
2732
2877
  try {
2733
2878
  if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2734
2879
  else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2735
- else if (resolveAs === "team") users = await this.expandTeamUsers(key);
2880
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
2736
2881
  else {
2737
2882
  throw new Error(
2738
2883
  `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
@@ -2757,9 +2902,50 @@ var _ApprovalService = class _ApprovalService {
2757
2902
  }
2758
2903
  return { slots, raw };
2759
2904
  }
2760
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
2761
- 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) {
2762
2947
  if (!teamId) return [];
2948
+ if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
2763
2949
  let rows = [];
2764
2950
  try {
2765
2951
  rows = await this.engine.find("sys_team_member", {
@@ -2771,7 +2957,149 @@ var _ApprovalService = class _ApprovalService {
2771
2957
  } catch {
2772
2958
  rows = [];
2773
2959
  }
2774
- 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;
2775
3103
  }
2776
3104
  /**
2777
3105
  * Tenant scope for a `sys_business_unit` read that may legitimately be
@@ -2851,9 +3179,48 @@ var _ApprovalService = class _ApprovalService {
2851
3179
  * Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
2852
3180
  * assignment table, keyed by the position's machine name (ADR-0057 D4),
2853
3181
  * unioned with the better-auth membership string (`sys_member.role`) as a
2854
- * transition source — the same semantics as `PositionGraphService` in
2855
- * `plugin-sharing`, so an approval routes to exactly the users the sharing
2856
- * engine would expand for the same position.
3182
+ * transition source.
3183
+ *
3184
+ * ⚠️ This is a ROUTING read (approver slates and escalation targets), and it
3185
+ * is deliberately NOT the same read as `PositionGraphService` in
3186
+ * `plugin-sharing`, whatever the shared method name suggests. Both answer
3187
+ * "who holds position P"; this one reads the directory RAW — neither the
3188
+ * ADR-0091 D2 validity window nor the `sys_position.active` catalogue flag is
3189
+ * applied. Maintainer ruling, 2026-08-15 (#8710, inheriting #8613), verbatim:
3190
+ *
3191
+ * > Access-conferring paths filter deactivated positions; addressing paths
3192
+ * > do not.
3193
+ *
3194
+ * Routing is an addressing path, so dropping a holder here is fail-OPEN, not
3195
+ * fail-closed: an expansion that comes back empty does not narrow the slate,
3196
+ * it falls through to the literal `position:` slot no user can ever act on —
3197
+ * the permanently stuck request of #3807 / #3424. A step routing to nobody is
3198
+ * worse than one routing to a lapsed holder, so the lapsed holder stays.
3199
+ *
3200
+ * Where the two implementations actually stand, per source. Both limbs are
3201
+ * listed because a statement about one of them is not a statement about this
3202
+ * method:
3203
+ *
3204
+ * 1. `sys_user_position` — sharing projects `valid_from` / `valid_until` and
3205
+ * drops rows on `isGrantActive` inside its own helper; we project
3206
+ * `user_id` alone, so an assignment that expired last month still routes.
3207
+ * This is the one real divergence, and it is the intended one.
3208
+ * 2. `sys_member.role` — raw on BOTH sides (`TeamGraphService.expandRoleUsers`
3209
+ * projects `user_id` too). The table carries no window columns at all and
3210
+ * `isGrantActive` reads an absent bound as unbounded, so there is nothing
3211
+ * a filter could do here; membership tier names have no `sys_position`
3212
+ * row either (#8710's "a name with no row is untouched" fallback), so no
3213
+ * catalogue flag either. This limb cannot be brought into parity by
3214
+ * adding a filter — see {@link expandMembershipTierUsers}.
3215
+ * 3. `sys_position.active` — the sharing engine's gate for it lives at the
3216
+ * RULE EVALUATOR's call site (`positionConfersAccess` in
3217
+ * `sharing-rule-service.ts`), not inside `PositionGraphService`; the same
3218
+ * ruling gives it no counterpart on this path.
3219
+ *
3220
+ * The omission is per-READ, not a missing dependency: `isGrantActive` is
3221
+ * imported in this file and IS applied to `sys_approval_delegation` in
3222
+ * {@link lookupActiveDelegation}. ⛔ So do not "fix" this by adding the window
3223
+ * filter here — that is the option #8710 rejected, on the reasoning above.
2857
3224
  */
2858
3225
  async expandPositionUsers(positionName, organizationId) {
2859
3226
  if (!positionName) return [];
@@ -2881,6 +3248,13 @@ var _ApprovalService = class _ApprovalService {
2881
3248
  * NOT positions. Named for the projection (`org_membership_level`, ADR-0057
2882
3249
  * D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
2883
3250
  * and stays, the platform-facing word does not.
3251
+ *
3252
+ * Read RAW, like every routing read here, and with nothing available to
3253
+ * filter even if it were not: `sys_member` carries no ADR-0091 D2 window
3254
+ * columns, and a tier name has no `sys_position` row to read `active` off.
3255
+ * {@link expandPositionUsers} carries the ruling both reads inherit
3256
+ * (#8613 / #8710) — this method is also the second limb of that union, so a
3257
+ * change here changes position routing too.
2884
3258
  */
2885
3259
  async expandMembershipTierUsers(tier, organizationId) {
2886
3260
  if (!tier) return [];
@@ -2894,7 +3268,36 @@ var _ApprovalService = class _ApprovalService {
2894
3268
  }
2895
3269
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2896
3270
  }
2897
- 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) {
2898
3301
  try {
2899
3302
  const rows = await this.engine.find("sys_user", {
2900
3303
  where: { id: userId },
@@ -2903,11 +3306,64 @@ var _ApprovalService = class _ApprovalService {
2903
3306
  context: SYSTEM_CTX2
2904
3307
  });
2905
3308
  const row = Array.isArray(rows) ? rows[0] : null;
2906
- 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;
2907
3313
  } catch {
2908
3314
  return null;
2909
3315
  }
2910
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
+ }
2911
3367
  /**
2912
3368
  * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
2913
3369
  * id, follow any active `sys_approval_delegation` chain and return the id the
@@ -5033,8 +5489,70 @@ var _ApprovalService = class _ApprovalService {
5033
5489
  return { requests: desired.size, inserted, deleted };
5034
5490
  }
5035
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
+ }
5036
5554
  /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
5037
- buildRequestWhere(filter, context) {
5555
+ async buildRequestWhere(filter, context) {
5038
5556
  const f = {};
5039
5557
  if (filter?.object) f.object_name = filter.object;
5040
5558
  if (filter?.recordId) f.record_id = filter.recordId;
@@ -5043,13 +5561,16 @@ var _ApprovalService = class _ApprovalService {
5043
5561
  if (tenantOrg) f.organization_id = tenantOrg;
5044
5562
  const q = filter?.q?.trim();
5045
5563
  if (q) {
5046
- f.$or = [
5564
+ const arms = [
5047
5565
  { process_name: { $contains: q } },
5048
5566
  { object_name: { $contains: q } },
5049
5567
  { record_id: { $contains: q } },
5050
- { submitter_id: { $contains: q } },
5051
- { payload_json: { $contains: q } }
5568
+ { submitter_id: { $contains: q } }
5052
5569
  ];
5570
+ if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
5571
+ arms.push({ payload_json: { $contains: q } });
5572
+ }
5573
+ f.$or = arms;
5053
5574
  }
5054
5575
  if (Array.isArray(filter?.status)) {
5055
5576
  const statuses = filter.status.filter(Boolean);
@@ -5110,7 +5631,7 @@ var _ApprovalService = class _ApprovalService {
5110
5631
  * is a plain membership test over the resolved ids). So this cannot hide a
5111
5632
  * request from someone who could actually act on it.
5112
5633
  */
5113
- async visibleRequestIds(context, tenantOrg) {
5634
+ async visibleRequestIds(context, tenantOrg, target) {
5114
5635
  if (this.isOverrideActor(context, tenantOrg)) return null;
5115
5636
  const uid2 = context?.userId != null ? String(context.userId) : "";
5116
5637
  if (!uid2) return /* @__PURE__ */ new Set();
@@ -5152,8 +5673,90 @@ var _ApprovalService = class _ApprovalService {
5152
5673
  error: err instanceof Error ? err.message : String(err)
5153
5674
  });
5154
5675
  }
5676
+ await this.addRecordReaderVisibleIds(ids, context, tenantOrg, target);
5155
5677
  return ids;
5156
5678
  }
5679
+ /**
5680
+ * [#8652] Read-only approval visibility derived from READ ACCESS TO THE
5681
+ * TARGET BUSINESS RECORD.
5682
+ *
5683
+ * Maintainer ruling 2026-08-15: a user who can read the target record may
5684
+ * view that record's approval requests and full action history, read-only,
5685
+ * behind a switch that is default OFF, anchored on the EXISTING record-read
5686
+ * permission. The rejected alternative was a host-injected visibility hook —
5687
+ * a security predicate the platform could neither constrain nor audit.
5688
+ *
5689
+ * ## How the anchor is evaluated
5690
+ *
5691
+ * By asking the engine to read the record AS THE CALLER. That is the whole
5692
+ * check: `engine.find(object, { where: { id }, context })` runs the ordinary
5693
+ * ObjectQL middleware — object CRUD read, then RLS — so a denial throws and a
5694
+ * row the caller may not see comes back empty. Both mean "no". No new
5695
+ * permission, role or grant type is invented, and no second copy of the
5696
+ * access rule exists to drift from the first.
5697
+ *
5698
+ * ⚠️ The caller's context is load-bearing. Probing with {@link SYSTEM_CTX} —
5699
+ * the context every other read in this service uses — would read exactly like
5700
+ * a permission check while admitting every authenticated user in the tenant.
5701
+ *
5702
+ * ## Why it needs a NAMED TARGET, and what that deliberately excludes
5703
+ *
5704
+ * The rule is anchored on one record, so it can only be evaluated where a
5705
+ * record is named: a list filtered by `object` + `recordId` (what a record
5706
+ * page's approval tab sends), or a request loaded by id (whose own row names
5707
+ * its target). An UNTARGETED list — the inbox — is left exactly as it was:
5708
+ * answering it under this tier would mean probing every request in the tenant
5709
+ * for read access, which is unbounded, and would turn a work queue into a
5710
+ * browse surface. The confirmed consumer is the record page; the inbox is not
5711
+ * part of the ruling and is not widened here.
5712
+ *
5713
+ * ## What becomes visible (stated plainly, because the switch is an opt-in)
5714
+ *
5715
+ * The request row — including its `payload` snapshot of the record at
5716
+ * submission time — plus the full action history: actor, decision, timestamp,
5717
+ * the action's COMMENT text, and (through the same gate, via
5718
+ * {@link ApprovalService.authorizeFileRead}) any decision attachments. The
5719
+ * comment text is the ruling's "full action history" read literally; it is
5720
+ * flagged on the card as the one granularity edge worth a second look.
5721
+ *
5722
+ * Read-only is not enforced here and must not be: the decision paths
5723
+ * (`decideNode` / `reassign` / `recall` / `comment`) authorize on the pending
5724
+ * approver slate, the submitter, or {@link ApprovalService.isOverrideActor},
5725
+ * none of which this tier touches. Seeing a request confers nothing.
5726
+ */
5727
+ async addRecordReaderVisibleIds(ids, context, tenantOrg, target) {
5728
+ if (this.recordReaderVisibleObjects.size === 0) return;
5729
+ const object = String(target?.object ?? "").trim();
5730
+ const recordId = String(target?.recordId ?? "").trim();
5731
+ if (!object || !recordId) return;
5732
+ if (!this.recordReaderVisibleObjects.has(object)) return;
5733
+ const uid2 = context?.userId != null ? String(context.userId) : "";
5734
+ if (!uid2) return;
5735
+ try {
5736
+ const readable = await this.engine.find(object, {
5737
+ where: { id: recordId },
5738
+ fields: ["id"],
5739
+ limit: 1,
5740
+ context
5741
+ });
5742
+ if (!Array.isArray(readable) || readable.length === 0) return;
5743
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
5744
+ const rows = await this.engine.find("sys_approval_request", {
5745
+ where: { object_name: object, record_id: recordId, ...orgWhere },
5746
+ fields: ["id"],
5747
+ limit: _ApprovalService.APPROVER_INDEX_CAP,
5748
+ context: SYSTEM_CTX2
5749
+ });
5750
+ for (const r of Array.isArray(rows) ? rows : []) {
5751
+ if (r?.id != null) ids.add(String(r.id));
5752
+ }
5753
+ } catch (err) {
5754
+ this.logger?.debug?.("[approvals] record-reader visibility probe declined", {
5755
+ object,
5756
+ error: err instanceof Error ? err.message : String(err)
5757
+ });
5758
+ }
5759
+ }
5157
5760
  /** Intersect an existing `where.id` constraint with the participant set. */
5158
5761
  applyVisibility(where, visible) {
5159
5762
  if (!visible) return true;
@@ -5170,14 +5773,17 @@ var _ApprovalService = class _ApprovalService {
5170
5773
  return true;
5171
5774
  }
5172
5775
  async listRequests(filter, context) {
5173
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
5776
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5174
5777
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5175
5778
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5176
5779
  if (ids) {
5177
5780
  if (ids.length === 0) return [];
5178
5781
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5179
5782
  }
5180
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
5783
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5784
+ object: filter?.object,
5785
+ recordId: filter?.recordId
5786
+ }))) return [];
5181
5787
  const findOpts = {
5182
5788
  where,
5183
5789
  orderBy: [{ field: "created_at", order: "desc" }],
@@ -5191,19 +5797,23 @@ var _ApprovalService = class _ApprovalService {
5191
5797
  }
5192
5798
  const rows = await this.engine.find("sys_approval_request", findOpts);
5193
5799
  const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
5800
+ await this.redactPayloads(list, context);
5194
5801
  await this.enrichRows(list);
5195
5802
  this.attachViewers(list, context);
5196
5803
  return list;
5197
5804
  }
5198
5805
  async countRequests(filter, context) {
5199
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
5806
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5200
5807
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5201
5808
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5202
5809
  if (ids) {
5203
5810
  if (ids.length === 0) return 0;
5204
5811
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
5205
5812
  }
5206
- if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
5813
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
5814
+ object: filter?.object,
5815
+ recordId: filter?.recordId
5816
+ }))) return 0;
5207
5817
  const countFn = this.engine.count;
5208
5818
  if (typeof countFn === "function") {
5209
5819
  try {
@@ -5249,10 +5859,14 @@ var _ApprovalService = class _ApprovalService {
5249
5859
  });
5250
5860
  if (!Array.isArray(rows) || !rows[0]) return null;
5251
5861
  if (enforceVisibility) {
5252
- const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
5862
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null, {
5863
+ object: rows[0].object_name,
5864
+ recordId: rows[0].record_id
5865
+ });
5253
5866
  if (visible && !visible.has(String(rows[0].id))) return null;
5254
5867
  }
5255
5868
  const row = rowFromRequest(rows[0]);
5869
+ await this.redactPayloads([row], context);
5256
5870
  await this.enrichRows([row]);
5257
5871
  await this.attachFlowSteps(row);
5258
5872
  await this.attachDecisionProgress(row, rows[0]);
@@ -5756,6 +6370,52 @@ function unbindAllHooks(engine) {
5756
6370
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
5757
6371
  }
5758
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
+
5759
6419
  // src/approval-node.ts
5760
6420
  var import_automation3 = require("@objectstack/spec/automation");
5761
6421
 
@@ -6001,6 +6661,9 @@ var ApprovalsServicePlugin = class {
6001
6661
  engine,
6002
6662
  logger: ctx.logger,
6003
6663
  publicBaseUrl: this.options.publicBaseUrl,
6664
+ // [#8652] Read-only record-reader visibility. Default OFF — an absent
6665
+ // declaration reaches the service as an empty set and changes nothing.
6666
+ recordReaderVisibleObjects: this.options.recordReaderVisibleObjects,
6004
6667
  // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
6005
6668
  // capability. Read LAZILY (not captured at start) because the tenancy
6006
6669
  // service resolves its posture during its own start, which may not have
@@ -6016,11 +6679,26 @@ var ApprovalsServicePlugin = class {
6016
6679
  }
6017
6680
  }
6018
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
+ });
6019
6696
  if (!this.options.disableAutoHooks) {
6020
6697
  try {
6021
6698
  unbindAllHooks(engine);
6022
6699
  bindApprovalLockHook(engine, ctx.logger);
6023
6700
  bindDelegationWriteGuard(engine, ctx.logger);
6701
+ bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
6024
6702
  } catch (err) {
6025
6703
  ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
6026
6704
  }
@@ -6060,6 +6738,7 @@ var ApprovalsServicePlugin = class {
6060
6738
  }
6061
6739
  };
6062
6740
  await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
6741
+ this.jobService = jobs;
6063
6742
  this.escalationJobScheduled = true;
6064
6743
  void sweep().catch((err) => {
6065
6744
  ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
@@ -6146,14 +6825,32 @@ var ApprovalsServicePlugin = class {
6146
6825
  );
6147
6826
  }
6148
6827
  }
6149
- 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() {
6150
6847
  if (this.escalationJobScheduled) {
6151
6848
  try {
6152
- const jobs = ctx.getService("job");
6153
- await jobs?.cancel?.(ESCALATION_JOB_NAME);
6849
+ await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
6154
6850
  } catch {
6155
6851
  }
6156
6852
  this.escalationJobScheduled = false;
6853
+ this.jobService = void 0;
6157
6854
  }
6158
6855
  if (this.engine) {
6159
6856
  try {
@@ -6162,6 +6859,17 @@ var ApprovalsServicePlugin = class {
6162
6859
  }
6163
6860
  }
6164
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
+ }
6165
6873
  };
6166
6874
  // Annotate the CommonJS export names for ESM import in node:
6167
6875
  0 && (module.exports = {