@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/CHANGELOG.md +589 -0
- package/dist/index.d.mts +3131 -618
- package/dist/index.d.ts +3131 -618
- package/dist/index.js +740 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +740 -32
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -13
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
|
-
/**
|
|
2761
|
-
|
|
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
|
-
|
|
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
|
|
2855
|
-
*
|
|
2856
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|