@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.mjs
CHANGED
|
@@ -1384,10 +1384,45 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1384
1384
|
},
|
|
1385
1385
|
fields: {
|
|
1386
1386
|
id: Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
|
|
1387
|
+
// ⚠️ MEASURED DEFECT, cloud#1395 — read this before trusting the column.
|
|
1388
|
+
//
|
|
1389
|
+
// An approval request DOES belong to an organization: it is read through the
|
|
1390
|
+
// organization wall by the approvals inbox, and on a shared-database
|
|
1391
|
+
// deployment a row carrying no organization is not filtered BY that wall —
|
|
1392
|
+
// it is either invisible to everyone or visible to everyone, decided by
|
|
1393
|
+
// whatever filter each surface happens to apply rather than by the data.
|
|
1394
|
+
//
|
|
1395
|
+
// The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
|
|
1396
|
+
// `ctxOrg`), so it is NULL whenever the flow that opened the request ran
|
|
1397
|
+
// without one — every schedule / time-relative / api triggered run, none of
|
|
1398
|
+
// which sets a tenant. On a walled single-database HotCRM SaaS boot this
|
|
1399
|
+
// measured 27 of 27 rows org-less, each naming an `object_name` /
|
|
1400
|
+
// `record_id` owned by a specific customer.
|
|
1401
|
+
//
|
|
1402
|
+
// ⛔ Do NOT read that as "platform tables do not carry an organization".
|
|
1403
|
+
// `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
|
|
1404
|
+
// because its writer takes the organization from the RECORD the row is
|
|
1405
|
+
// about, with the session only as fallback (plugin-audit
|
|
1406
|
+
// `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
|
|
1407
|
+
// writers read the actor; a third reads the subject. That disagreement is
|
|
1408
|
+
// the defect.
|
|
1409
|
+
//
|
|
1410
|
+
// Which of the two a side-table row should follow is an open contract
|
|
1411
|
+
// question on cloud#1395 — the audit resolver is scope-pinned to audit
|
|
1412
|
+
// stamping by the #8778 ruling, so this writer needs its own. The same
|
|
1413
|
+
// `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
|
|
1414
|
+
// so all three move together.
|
|
1387
1415
|
organization_id: Field.lookup("sys_organization", {
|
|
1388
1416
|
label: "Organization",
|
|
1389
1417
|
required: false,
|
|
1390
1418
|
group: "System",
|
|
1419
|
+
// ⛔ String unchanged on purpose: it is extracted into the generated i18n
|
|
1420
|
+
// bundles (`translations/*.objects.generated.ts`, as `help`), so rewording
|
|
1421
|
+
// it is a translation-regeneration change and not a comment. The
|
|
1422
|
+
// correction it needs — it claims a propagation that measurably does not
|
|
1423
|
+
// happen, and says "Tenant" where ADR-0120 §Terminology requires
|
|
1424
|
+
// "organization" — rides the cloud#1395 write-side fix, which rewrites the
|
|
1425
|
+
// sentence and regenerates the four locales in one pass.
|
|
1391
1426
|
description: "Tenant that owns this approval request (propagated from submitter context)"
|
|
1392
1427
|
}),
|
|
1393
1428
|
process_name: Field.text({
|
|
@@ -1531,6 +1566,28 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1531
1566
|
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
1532
1567
|
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1533
1568
|
// closed.
|
|
1569
|
+
//
|
|
1570
|
+
// Every predicate below is guarded for the SPARSE action face (#8990). This
|
|
1571
|
+
// binding is a list row or a record read carrying only what the caller
|
|
1572
|
+
// projected, and CEL aborts the whole expression at key resolution — so the
|
|
1573
|
+
// unguarded `record.viewer.can_act` faulted (`No such key: viewer`) on any
|
|
1574
|
+
// row without the block, and the button silently vanished, indistinguishable
|
|
1575
|
+
// from "the gate said no". `materializeDeclaredFields`'s doc comment in
|
|
1576
|
+
// `@objectstack/objectql` is the canonical statement of the guard rule; this
|
|
1577
|
+
// file follows it and does not restate it.
|
|
1578
|
+
//
|
|
1579
|
+
// `viewer` is a NESTED block, which needs one measurement the canonical rule
|
|
1580
|
+
// does not spell out. Measured against the `@objectstack/formula` CEL engine:
|
|
1581
|
+
// `has(record.viewer) && record.viewer != null && record.viewer.can_act`
|
|
1582
|
+
// still FAULTS on `{viewer: {}}` (`No such key: can_act`) and on
|
|
1583
|
+
// `{viewer: {can_act: null}}` (`Logical operator requires bool operands`).
|
|
1584
|
+
// Guarding the LEAF instead — `has(record.viewer) &&
|
|
1585
|
+
// has(record.viewer.can_act) && record.viewer.can_act == true` — is total
|
|
1586
|
+
// over every binding AND subsumes the parent `!= null` half, because `has()`
|
|
1587
|
+
// on a path whose parent is null answers `false` rather than faulting. So the
|
|
1588
|
+
// leaf `has()` plus the `== true` comparison is the MINIMAL safe form here,
|
|
1589
|
+
// not a longer one: `== true` is load-bearing (a bare truthy read of a null
|
|
1590
|
+
// leaf faults the logical operator), the parent `!= null` is not.
|
|
1534
1591
|
actions: [
|
|
1535
1592
|
{
|
|
1536
1593
|
name: "approval_approve",
|
|
@@ -1550,7 +1607,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1550
1607
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1551
1608
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1552
1609
|
],
|
|
1553
|
-
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1610
|
+
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",
|
|
1554
1611
|
locations: ["record_section", "list_item"],
|
|
1555
1612
|
successMessage: "Approved.",
|
|
1556
1613
|
refreshAfter: true
|
|
@@ -1578,7 +1635,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1578
1635
|
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1579
1636
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1580
1637
|
],
|
|
1581
|
-
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1638
|
+
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",
|
|
1582
1639
|
locations: ["record_section", "list_item"],
|
|
1583
1640
|
successMessage: "Rejected.",
|
|
1584
1641
|
refreshAfter: true
|
|
@@ -1599,7 +1656,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1599
1656
|
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1600
1657
|
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1601
1658
|
],
|
|
1602
|
-
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1659
|
+
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",
|
|
1603
1660
|
locations: ["record_section"],
|
|
1604
1661
|
successMessage: "Reassigned.",
|
|
1605
1662
|
refreshAfter: true
|
|
@@ -1618,7 +1675,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1618
1675
|
params: [
|
|
1619
1676
|
{ name: "comment", label: "Reason", type: "textarea", required: false }
|
|
1620
1677
|
],
|
|
1621
|
-
visible: "record.viewer.can_act",
|
|
1678
|
+
visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
|
|
1622
1679
|
locations: ["record_section"],
|
|
1623
1680
|
successMessage: "Sent back for revision.",
|
|
1624
1681
|
refreshAfter: true
|
|
@@ -1633,7 +1690,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1633
1690
|
params: [
|
|
1634
1691
|
{ name: "comment", label: "What do you need?", type: "textarea", required: true }
|
|
1635
1692
|
],
|
|
1636
|
-
visible: "record.viewer.can_act",
|
|
1693
|
+
visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
|
|
1637
1694
|
locations: ["record_section"],
|
|
1638
1695
|
successMessage: "Information requested.",
|
|
1639
1696
|
refreshAfter: true
|
|
@@ -1654,7 +1711,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1654
1711
|
params: [
|
|
1655
1712
|
{ name: "comment", label: "Note", type: "textarea", required: false }
|
|
1656
1713
|
],
|
|
1657
|
-
visible: 'record.status == "pending" && record.viewer.is_submitter',
|
|
1714
|
+
visible: 'has(record.status) && record.status == "pending" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
|
|
1658
1715
|
locations: ["record_section"],
|
|
1659
1716
|
successMessage: "Reminder sent.",
|
|
1660
1717
|
refreshAfter: true
|
|
@@ -1674,7 +1731,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1674
1731
|
],
|
|
1675
1732
|
// Recall applies while the request is live for the submitter — pending
|
|
1676
1733
|
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1677
|
-
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
|
|
1734
|
+
visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
|
|
1678
1735
|
locations: ["record_section"],
|
|
1679
1736
|
successMessage: "Recalled.",
|
|
1680
1737
|
refreshAfter: true
|
|
@@ -1689,7 +1746,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1689
1746
|
params: [
|
|
1690
1747
|
{ name: "comment", label: "What changed?", type: "textarea", required: false }
|
|
1691
1748
|
],
|
|
1692
|
-
visible: 'record.status == "returned" && record.viewer.is_submitter',
|
|
1749
|
+
visible: 'has(record.status) && record.status == "returned" && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
|
|
1693
1750
|
locations: ["record_section"],
|
|
1694
1751
|
successMessage: "Resubmitted.",
|
|
1695
1752
|
refreshAfter: true
|
|
@@ -2163,6 +2220,38 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
|
2163
2220
|
return userIds.filter((u) => canRead.has(u));
|
|
2164
2221
|
}
|
|
2165
2222
|
|
|
2223
|
+
// src/payload-redaction.ts
|
|
2224
|
+
function redactSnapshot(payload, readable) {
|
|
2225
|
+
if (readable === void 0) return { payload, redactedKeys: [] };
|
|
2226
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2227
|
+
return { payload, redactedKeys: [] };
|
|
2228
|
+
}
|
|
2229
|
+
const allowed = new Set(readable.map((f) => String(f)));
|
|
2230
|
+
const source = payload;
|
|
2231
|
+
const redactedKeys = [];
|
|
2232
|
+
const kept = {};
|
|
2233
|
+
for (const key of Object.keys(source)) {
|
|
2234
|
+
if (allowed.has(key)) kept[key] = source[key];
|
|
2235
|
+
else redactedKeys.push(key);
|
|
2236
|
+
}
|
|
2237
|
+
if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
|
|
2238
|
+
return { payload: kept, redactedKeys: redactedKeys.sort() };
|
|
2239
|
+
}
|
|
2240
|
+
async function resolveReadableSnapshotFields(security, objectName, context, logger) {
|
|
2241
|
+
if (!security || typeof security.getReadableFields !== "function") return void 0;
|
|
2242
|
+
const object = String(objectName ?? "").trim();
|
|
2243
|
+
if (!object) return void 0;
|
|
2244
|
+
try {
|
|
2245
|
+
return await security.getReadableFields(object, context);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
|
|
2248
|
+
object,
|
|
2249
|
+
error: err?.message ?? String(err)
|
|
2250
|
+
});
|
|
2251
|
+
return void 0;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2166
2255
|
// src/approval-service.ts
|
|
2167
2256
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
2168
2257
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
@@ -2183,6 +2272,7 @@ function actingUserId(context) {
|
|
|
2183
2272
|
return typeof userId === "string" && userId ? userId : null;
|
|
2184
2273
|
}
|
|
2185
2274
|
var OOO_MAX_CHAIN = 8;
|
|
2275
|
+
var MEMBER_SCREEN_READ_LIMIT = 5e4;
|
|
2186
2276
|
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2187
2277
|
"team",
|
|
2188
2278
|
"department",
|
|
@@ -2335,11 +2425,66 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2335
2425
|
this.messaging = opts.messaging;
|
|
2336
2426
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2337
2427
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2428
|
+
this.fieldVisibility = opts.fieldVisibility;
|
|
2429
|
+
this.recordReaderVisibleObjects = new Set(
|
|
2430
|
+
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2431
|
+
);
|
|
2338
2432
|
}
|
|
2339
2433
|
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
2340
2434
|
attachTenancyPosture(provider) {
|
|
2341
2435
|
this.tenancyPosture = provider;
|
|
2342
2436
|
}
|
|
2437
|
+
/**
|
|
2438
|
+
* [#10749] Attach (or replace) the field-visibility authority the payload
|
|
2439
|
+
* redaction seam reads. Late-bound: plugin load order does not guarantee the
|
|
2440
|
+
* security service exists when this one is constructed.
|
|
2441
|
+
*/
|
|
2442
|
+
attachFieldVisibility(source) {
|
|
2443
|
+
this.fieldVisibility = source;
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* [#10749] Redact each row's payload snapshot down to the fields the READING
|
|
2447
|
+
* caller may see on that row's subject object.
|
|
2448
|
+
*
|
|
2449
|
+
* Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
|
|
2450
|
+
* load-bearing rather than incidental: `enrichRows` derives `payload_display`
|
|
2451
|
+
* (lookup foreign keys inside the snapshot resolved to referenced record
|
|
2452
|
+
* titles) and `payload_labels` (a label per snapshot key) by WALKING THE
|
|
2453
|
+
* SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
|
|
2454
|
+
* redact after and a restricted field's name, its authored label and the
|
|
2455
|
+
* title of the record it points at all still ship — the value would be gone
|
|
2456
|
+
* and the disclosure would not.
|
|
2457
|
+
*
|
|
2458
|
+
* Rows are grouped by subject object so one `getReadableFields` call covers a
|
|
2459
|
+
* whole page of same-object requests.
|
|
2460
|
+
*/
|
|
2461
|
+
async redactPayloads(rows, context) {
|
|
2462
|
+
const withPayload = rows.filter((r) => r?.payload != null);
|
|
2463
|
+
if (withPayload.length === 0) return;
|
|
2464
|
+
const byObject = /* @__PURE__ */ new Map();
|
|
2465
|
+
for (const r of withPayload) {
|
|
2466
|
+
const key = String(r.object_name ?? "");
|
|
2467
|
+
let list = byObject.get(key);
|
|
2468
|
+
if (!list) {
|
|
2469
|
+
list = [];
|
|
2470
|
+
byObject.set(key, list);
|
|
2471
|
+
}
|
|
2472
|
+
list.push(r);
|
|
2473
|
+
}
|
|
2474
|
+
for (const [object, group] of byObject) {
|
|
2475
|
+
const readable = await resolveReadableSnapshotFields(
|
|
2476
|
+
this.fieldVisibility,
|
|
2477
|
+
object,
|
|
2478
|
+
context,
|
|
2479
|
+
this.logger
|
|
2480
|
+
);
|
|
2481
|
+
if (readable === void 0) continue;
|
|
2482
|
+
for (const r of group) {
|
|
2483
|
+
const { payload } = redactSnapshot(r.payload, readable);
|
|
2484
|
+
r.payload = payload;
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2343
2488
|
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2344
2489
|
get orgScopeDeps() {
|
|
2345
2490
|
return {
|
|
@@ -2606,7 +2751,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2606
2751
|
}) : users;
|
|
2607
2752
|
try {
|
|
2608
2753
|
if (type === "team") {
|
|
2609
|
-
const users = await this.expandTeamUsers(String(a.value));
|
|
2754
|
+
const users = await this.expandTeamUsers(String(a.value), organizationId);
|
|
2610
2755
|
if (users.length) return users;
|
|
2611
2756
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
2612
2757
|
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
@@ -2620,7 +2765,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2620
2765
|
} else if (type === "manager" && record) {
|
|
2621
2766
|
const subject = record[a.value] ?? record.owner_id;
|
|
2622
2767
|
if (subject) {
|
|
2623
|
-
const mgr = await this.lookupManager(String(subject));
|
|
2768
|
+
const mgr = await this.lookupManager(String(subject), organizationId);
|
|
2624
2769
|
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
2625
2770
|
}
|
|
2626
2771
|
}
|
|
@@ -2717,7 +2862,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2717
2862
|
try {
|
|
2718
2863
|
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2719
2864
|
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2720
|
-
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
2865
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
|
|
2721
2866
|
else {
|
|
2722
2867
|
throw new Error(
|
|
2723
2868
|
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
@@ -2742,9 +2887,50 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2742
2887
|
}
|
|
2743
2888
|
return { slots, raw };
|
|
2744
2889
|
}
|
|
2745
|
-
/**
|
|
2746
|
-
|
|
2890
|
+
/**
|
|
2891
|
+
* Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
|
|
2892
|
+
*
|
|
2893
|
+
* Takes an organization for the reason every sibling expansion does
|
|
2894
|
+
* ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
|
|
2895
|
+
* {@link expandMembershipTierUsers}): an approver expansion answers "who, in
|
|
2896
|
+
* THIS organization". Before #10230 this one did not ask, and it was the last
|
|
2897
|
+
* expansion that did not — a `team` approver naming ANOTHER organization's
|
|
2898
|
+
* team routed that organization's people an approval over a record they are
|
|
2899
|
+
* not a tenant of.
|
|
2900
|
+
*
|
|
2901
|
+
* TWO screens run here, and they assert different things (#10230, #10547):
|
|
2902
|
+
*
|
|
2903
|
+
* 1. the TEAM must not provably belong to another organization
|
|
2904
|
+
* ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
|
|
2905
|
+
* `organization_id` outright
|
|
2906
|
+
* (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
|
|
2907
|
+
* team id transitively names exactly one organization and ONE row
|
|
2908
|
+
* answers the question;
|
|
2909
|
+
* 2. each expanded MEMBER must not provably hold membership only in other
|
|
2910
|
+
* organizations ({@link dropMembersProvablyOutsideOrg}) —
|
|
2911
|
+
* `sys_team_member` carries `team_id` and `user_id` and NO tenancy
|
|
2912
|
+
* column at all, so passing (1) says nothing whatever about the people
|
|
2913
|
+
* it lists.
|
|
2914
|
+
*
|
|
2915
|
+
* #10230 landed (1) alone and deferred (2) on purpose. What closed the
|
|
2916
|
+
* deferral is that (1) does not imply (2) even a little: a member removed
|
|
2917
|
+
* from the organization but left on the team, a team re-parented across
|
|
2918
|
+
* organizations (`/organization/update-team` accepts `organizationId` in its
|
|
2919
|
+
* partial body), or a `sys_team_member` row written by a seed rather than
|
|
2920
|
+
* through better-auth all produce a team that passes (1) carrying a user who
|
|
2921
|
+
* is provably a tenant of somewhere else. Measured on this tree, not read off
|
|
2922
|
+
* the schema — the probe is quoted in `team-member-org-screen.test.ts`.
|
|
2923
|
+
*
|
|
2924
|
+
* (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
|
|
2925
|
+
* further out, and it is asserted the same way: `sys_user` carries no tenancy
|
|
2926
|
+
* fact, so `sys_member` rows are the only evidence that a person is placed
|
|
2927
|
+
* anywhere. Like that screen, this one grants no reads and applies no read
|
|
2928
|
+
* screen to any approver type that lacks one today, so it decides nothing
|
|
2929
|
+
* #7497 (does approver routing imply record read visibility?) asks.
|
|
2930
|
+
*/
|
|
2931
|
+
async expandTeamUsers(teamId, organizationId) {
|
|
2747
2932
|
if (!teamId) return [];
|
|
2933
|
+
if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
|
|
2748
2934
|
let rows = [];
|
|
2749
2935
|
try {
|
|
2750
2936
|
rows = await this.engine.find("sys_team_member", {
|
|
@@ -2756,7 +2942,149 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2756
2942
|
} catch {
|
|
2757
2943
|
rows = [];
|
|
2758
2944
|
}
|
|
2759
|
-
|
|
2945
|
+
const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
2946
|
+
return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
|
|
2950
|
+
*
|
|
2951
|
+
* "Provably" carries the same posture the sibling screen states at length in
|
|
2952
|
+
* {@link managerIsProvablyOutsideOrg}, for the same reasons:
|
|
2953
|
+
*
|
|
2954
|
+
* - the team row carries an `organization_id` and it is not the request's
|
|
2955
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
|
|
2956
|
+
* - the row carries no `organization_id`, does not exist, or the read failed
|
|
2957
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
2958
|
+
*
|
|
2959
|
+
* The `organization_id = null` limb is not timidity — it is the reading
|
|
2960
|
+
* {@link businessUnitOrgScope} settled on one screen below, for the identical
|
|
2961
|
+
* shape: null on a platform object means "owned by no organization", which is
|
|
2962
|
+
* what a seed writes because a seed cannot know the organization id the
|
|
2963
|
+
* runtime mints at boot. Treating null as "not mine" would delete every
|
|
2964
|
+
* seeded team approver at once — a larger behaviour change than the hole
|
|
2965
|
+
* being closed. Measured, and not hypothetically: this package's own
|
|
2966
|
+
* `team_ok` expansion fixture is exactly such a stack (it has
|
|
2967
|
+
* `sys_team_member` rows, a request carrying an organization, and no
|
|
2968
|
+
* `sys_team` row at all).
|
|
2969
|
+
*
|
|
2970
|
+
* Screening the TEAM before reading its members is also what keeps the cost
|
|
2971
|
+
* at one row: a team that fails the screen never fans out.
|
|
2972
|
+
*/
|
|
2973
|
+
async teamIsProvablyOutsideOrg(teamId, organizationId) {
|
|
2974
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
2975
|
+
if (!requestOrg) return false;
|
|
2976
|
+
let rows = [];
|
|
2977
|
+
try {
|
|
2978
|
+
rows = await this.engine.find("sys_team", {
|
|
2979
|
+
where: { id: teamId },
|
|
2980
|
+
fields: ["id", "organization_id"],
|
|
2981
|
+
limit: 1,
|
|
2982
|
+
context: SYSTEM_CTX2
|
|
2983
|
+
});
|
|
2984
|
+
} catch {
|
|
2985
|
+
return false;
|
|
2986
|
+
}
|
|
2987
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2988
|
+
const teamOrg = row?.organization_id ? String(row.organization_id) : "";
|
|
2989
|
+
if (!teamOrg) return false;
|
|
2990
|
+
if (teamOrg === requestOrg) return false;
|
|
2991
|
+
this.logger?.warn?.(
|
|
2992
|
+
`[approvals] #10230: team '${teamId}' was dropped from the approver slate \u2014 'sys_team.organization_id' is '${teamOrg}', not the request's organization '${requestOrg}', so routing this approval to its members would put approval authority over the record outside its tenant. Point the approver at a team in this organization, or route this step with an approver type that names someone in it.`,
|
|
2993
|
+
{ teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
|
|
2994
|
+
);
|
|
2995
|
+
return true;
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Drop the expanded team members who are PROVABLY tenants of other
|
|
2999
|
+
* organizations and not of `organizationId`. (#10547)
|
|
3000
|
+
*
|
|
3001
|
+
* Returns the survivors, in the order they were expanded.
|
|
3002
|
+
*
|
|
3003
|
+
* Posture — identical to {@link managerIsProvablyOutsideOrg} and
|
|
3004
|
+
* {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
|
|
3005
|
+
* assertion about the same table:
|
|
3006
|
+
*
|
|
3007
|
+
* - membership rows exist for this user, none in `organizationId`
|
|
3008
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
|
|
3009
|
+
* - no membership rows at all for him, the read failed, or the request
|
|
3010
|
+
* carries no organization
|
|
3011
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3012
|
+
*
|
|
3013
|
+
* The absent limb is load-bearing rather than timid, and #3807 is the recorded
|
|
3014
|
+
* cost of getting it wrong: a stack that stamps an organization on requests
|
|
3015
|
+
* but never materializes `sys_member` rows would otherwise lose EVERY team
|
|
3016
|
+
* approver at once. This package's own `team_ok` expansion fixture and
|
|
3017
|
+
* #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
|
|
3018
|
+
* a request organization and no `sys_member` table at all — so the absent
|
|
3019
|
+
* limb is exercised by neighbours on every run of this suite.
|
|
3020
|
+
*
|
|
3021
|
+
* ONE read for the whole slate, never one per person: the expansion is capped
|
|
3022
|
+
* at 10000 members and a per-user query would turn a single team approver
|
|
3023
|
+
* into 10000 round trips.
|
|
3024
|
+
*
|
|
3025
|
+
* ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
|
|
3026
|
+
* the only evidence that a member IS a tenant here, so a result cut off at
|
|
3027
|
+
* the limit could be missing the very row that keeps a legitimate approver on
|
|
3028
|
+
* the slate — screening him out on missing evidence, which inverts the
|
|
3029
|
+
* posture into fail-CLOSED precisely where it must not. When the read comes
|
|
3030
|
+
* back at the cap it is treated as no evidence at all.
|
|
3031
|
+
*/
|
|
3032
|
+
async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
|
|
3033
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3034
|
+
if (!requestOrg || !userIds.length) return userIds;
|
|
3035
|
+
let rows = [];
|
|
3036
|
+
try {
|
|
3037
|
+
rows = await this.engine.find("sys_member", {
|
|
3038
|
+
where: { user_id: { $in: userIds } },
|
|
3039
|
+
fields: ["user_id", "organization_id"],
|
|
3040
|
+
limit: MEMBER_SCREEN_READ_LIMIT,
|
|
3041
|
+
context: SYSTEM_CTX2
|
|
3042
|
+
});
|
|
3043
|
+
} catch {
|
|
3044
|
+
return userIds;
|
|
3045
|
+
}
|
|
3046
|
+
if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
|
|
3047
|
+
this.logger?.warn?.(
|
|
3048
|
+
`[approvals] #10547: the membership screen for team '${teamId}' read ${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row cap, so the result may be truncated. Routing is left unchanged rather than risk dropping a member whose proof of membership fell outside the read.`,
|
|
3049
|
+
{ teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
|
|
3050
|
+
);
|
|
3051
|
+
return userIds;
|
|
3052
|
+
}
|
|
3053
|
+
const orgsByUser = /* @__PURE__ */ new Map();
|
|
3054
|
+
for (const r of rows ?? []) {
|
|
3055
|
+
const uid2 = String(r?.user_id ?? "");
|
|
3056
|
+
const org = String(r?.organization_id ?? "");
|
|
3057
|
+
if (!uid2 || !org) continue;
|
|
3058
|
+
const seen = orgsByUser.get(uid2);
|
|
3059
|
+
if (seen) seen.push(org);
|
|
3060
|
+
else orgsByUser.set(uid2, [org]);
|
|
3061
|
+
}
|
|
3062
|
+
const kept = [];
|
|
3063
|
+
const dropped = [];
|
|
3064
|
+
for (const uid2 of userIds) {
|
|
3065
|
+
const orgs = orgsByUser.get(uid2);
|
|
3066
|
+
if (!orgs?.length) {
|
|
3067
|
+
kept.push(uid2);
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
if (orgs.includes(requestOrg)) {
|
|
3071
|
+
kept.push(uid2);
|
|
3072
|
+
continue;
|
|
3073
|
+
}
|
|
3074
|
+
dropped.push({ userId: uid2, organizationIds: orgs });
|
|
3075
|
+
}
|
|
3076
|
+
if (dropped.length) {
|
|
3077
|
+
this.logger?.warn?.(
|
|
3078
|
+
`[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from the approver slate \u2014 ${dropped.map((d) => `'${d.userId}'`).join(", ")} hold membership in other organization(s), none of them the request's organization '${requestOrg}', so routing this approval to them would put approval authority over the record outside its tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry no organization of their own. Remove them from the team, grant them a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3079
|
+
{
|
|
3080
|
+
teamId,
|
|
3081
|
+
requestOrganizationId: requestOrg,
|
|
3082
|
+
droppedUserIds: dropped.map((d) => d.userId),
|
|
3083
|
+
droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
|
|
3084
|
+
}
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
return kept;
|
|
2760
3088
|
}
|
|
2761
3089
|
/**
|
|
2762
3090
|
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
@@ -2836,9 +3164,48 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2836
3164
|
* Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
|
|
2837
3165
|
* assignment table, keyed by the position's machine name (ADR-0057 D4),
|
|
2838
3166
|
* unioned with the better-auth membership string (`sys_member.role`) as a
|
|
2839
|
-
* transition source
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
3167
|
+
* transition source.
|
|
3168
|
+
*
|
|
3169
|
+
* ⚠️ This is a ROUTING read (approver slates and escalation targets), and it
|
|
3170
|
+
* is deliberately NOT the same read as `PositionGraphService` in
|
|
3171
|
+
* `plugin-sharing`, whatever the shared method name suggests. Both answer
|
|
3172
|
+
* "who holds position P"; this one reads the directory RAW — neither the
|
|
3173
|
+
* ADR-0091 D2 validity window nor the `sys_position.active` catalogue flag is
|
|
3174
|
+
* applied. Maintainer ruling, 2026-08-15 (#8710, inheriting #8613), verbatim:
|
|
3175
|
+
*
|
|
3176
|
+
* > Access-conferring paths filter deactivated positions; addressing paths
|
|
3177
|
+
* > do not.
|
|
3178
|
+
*
|
|
3179
|
+
* Routing is an addressing path, so dropping a holder here is fail-OPEN, not
|
|
3180
|
+
* fail-closed: an expansion that comes back empty does not narrow the slate,
|
|
3181
|
+
* it falls through to the literal `position:` slot no user can ever act on —
|
|
3182
|
+
* the permanently stuck request of #3807 / #3424. A step routing to nobody is
|
|
3183
|
+
* worse than one routing to a lapsed holder, so the lapsed holder stays.
|
|
3184
|
+
*
|
|
3185
|
+
* Where the two implementations actually stand, per source. Both limbs are
|
|
3186
|
+
* listed because a statement about one of them is not a statement about this
|
|
3187
|
+
* method:
|
|
3188
|
+
*
|
|
3189
|
+
* 1. `sys_user_position` — sharing projects `valid_from` / `valid_until` and
|
|
3190
|
+
* drops rows on `isGrantActive` inside its own helper; we project
|
|
3191
|
+
* `user_id` alone, so an assignment that expired last month still routes.
|
|
3192
|
+
* This is the one real divergence, and it is the intended one.
|
|
3193
|
+
* 2. `sys_member.role` — raw on BOTH sides (`TeamGraphService.expandRoleUsers`
|
|
3194
|
+
* projects `user_id` too). The table carries no window columns at all and
|
|
3195
|
+
* `isGrantActive` reads an absent bound as unbounded, so there is nothing
|
|
3196
|
+
* a filter could do here; membership tier names have no `sys_position`
|
|
3197
|
+
* row either (#8710's "a name with no row is untouched" fallback), so no
|
|
3198
|
+
* catalogue flag either. This limb cannot be brought into parity by
|
|
3199
|
+
* adding a filter — see {@link expandMembershipTierUsers}.
|
|
3200
|
+
* 3. `sys_position.active` — the sharing engine's gate for it lives at the
|
|
3201
|
+
* RULE EVALUATOR's call site (`positionConfersAccess` in
|
|
3202
|
+
* `sharing-rule-service.ts`), not inside `PositionGraphService`; the same
|
|
3203
|
+
* ruling gives it no counterpart on this path.
|
|
3204
|
+
*
|
|
3205
|
+
* The omission is per-READ, not a missing dependency: `isGrantActive` is
|
|
3206
|
+
* imported in this file and IS applied to `sys_approval_delegation` in
|
|
3207
|
+
* {@link lookupActiveDelegation}. ⛔ So do not "fix" this by adding the window
|
|
3208
|
+
* filter here — that is the option #8710 rejected, on the reasoning above.
|
|
2842
3209
|
*/
|
|
2843
3210
|
async expandPositionUsers(positionName, organizationId) {
|
|
2844
3211
|
if (!positionName) return [];
|
|
@@ -2866,6 +3233,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2866
3233
|
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
2867
3234
|
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
2868
3235
|
* and stays, the platform-facing word does not.
|
|
3236
|
+
*
|
|
3237
|
+
* Read RAW, like every routing read here, and with nothing available to
|
|
3238
|
+
* filter even if it were not: `sys_member` carries no ADR-0091 D2 window
|
|
3239
|
+
* columns, and a tier name has no `sys_position` row to read `active` off.
|
|
3240
|
+
* {@link expandPositionUsers} carries the ruling both reads inherit
|
|
3241
|
+
* (#8613 / #8710) — this method is also the second limb of that union, so a
|
|
3242
|
+
* change here changes position routing too.
|
|
2869
3243
|
*/
|
|
2870
3244
|
async expandMembershipTierUsers(tier, organizationId) {
|
|
2871
3245
|
if (!tier) return [];
|
|
@@ -2879,7 +3253,36 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2879
3253
|
}
|
|
2880
3254
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
2881
3255
|
}
|
|
2882
|
-
|
|
3256
|
+
/**
|
|
3257
|
+
* `sys_user.manager_id`, screened to the request's organization (#10153).
|
|
3258
|
+
*
|
|
3259
|
+
* Takes an organization argument for the same reason its siblings do
|
|
3260
|
+
* ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
|
|
3261
|
+
* approver expansion answers "who, in THIS organization". Before #10153 this
|
|
3262
|
+
* one did not ask, and it was the only expansion that did not — a
|
|
3263
|
+
* `manager_id` pointing at a person in another organization routed that
|
|
3264
|
+
* person an approval over a record they are not a tenant of.
|
|
3265
|
+
*
|
|
3266
|
+
* ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
|
|
3267
|
+
* filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
|
|
3268
|
+
* comment exists so the next reader does not conclude that #7497 (does
|
|
3269
|
+
* approver routing imply record read visibility?) was settled here. It was
|
|
3270
|
+
* not. Two facts make this the SIBLING treatment rather than a
|
|
3271
|
+
* read-visibility ruling:
|
|
3272
|
+
*
|
|
3273
|
+
* 1. Two of the three org-scoped expansions already screen on exactly this
|
|
3274
|
+
* column — `expandMembershipTierUsers` filters `sys_member.organization_id`
|
|
3275
|
+
* outright, and it is also the second limb of `expandPositionUsers`. So
|
|
3276
|
+
* `sys_member.organization_id` is already this file's answer to "which
|
|
3277
|
+
* organization is this person in", independent of what they may read.
|
|
3278
|
+
* 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
|
|
3279
|
+
* table, so a membership row is the only tenancy fact that exists for a
|
|
3280
|
+
* user — there is no other read this screen could have been written with.
|
|
3281
|
+
*
|
|
3282
|
+
* This change grants no reads and applies no read screen to any type that
|
|
3283
|
+
* lacks one today, so it decides nothing #7497 asks.
|
|
3284
|
+
*/
|
|
3285
|
+
async lookupManager(userId, organizationId) {
|
|
2883
3286
|
try {
|
|
2884
3287
|
const rows = await this.engine.find("sys_user", {
|
|
2885
3288
|
where: { id: userId },
|
|
@@ -2888,11 +3291,64 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2888
3291
|
context: SYSTEM_CTX2
|
|
2889
3292
|
});
|
|
2890
3293
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2891
|
-
|
|
3294
|
+
const managerId = row?.manager_id ? String(row.manager_id) : null;
|
|
3295
|
+
if (!managerId) return null;
|
|
3296
|
+
if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
|
|
3297
|
+
return managerId;
|
|
2892
3298
|
} catch {
|
|
2893
3299
|
return null;
|
|
2894
3300
|
}
|
|
2895
3301
|
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Is `managerId` PROVABLY a member of other organizations and not of
|
|
3304
|
+
* `organizationId`? (#10153)
|
|
3305
|
+
*
|
|
3306
|
+
* "Provably" is the whole shape of this screen, and it is deliberate rather
|
|
3307
|
+
* than a weaker version of "must prove membership":
|
|
3308
|
+
*
|
|
3309
|
+
* - membership rows exist for this user, none in the request's org
|
|
3310
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
|
|
3311
|
+
* - no membership rows at all, or the read failed
|
|
3312
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3313
|
+
*
|
|
3314
|
+
* The fail-open half is not timidity, it is this file's ruled posture on
|
|
3315
|
+
* addressing paths, stated twice already: {@link filterApproversWhoCanRead}
|
|
3316
|
+
* refuses to empty a live slate on an infrastructure hiccup, and
|
|
3317
|
+
* {@link expandPositionUsers} carries "a step routing to nobody is worse than
|
|
3318
|
+
* one routing to a lapsed holder". It is also load-bearing in practice — a
|
|
3319
|
+
* stack that stamps an organization on its requests but does not materialize
|
|
3320
|
+
* `sys_member` rows would otherwise lose every manager approver at once,
|
|
3321
|
+
* which is a bigger behaviour change than the hole being closed. Measured:
|
|
3322
|
+
* this repo's own `type:manager` out-of-office fixture is such a stack.
|
|
3323
|
+
*
|
|
3324
|
+
* Screening the MANAGER only, before OOO delegation, is deliberate too: the
|
|
3325
|
+
* delegate arrives from `sys_approval_delegation`, whose rows already carry
|
|
3326
|
+
* (and are already filtered by) an `organization_id` in
|
|
3327
|
+
* {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
|
|
3328
|
+
*/
|
|
3329
|
+
async managerIsProvablyOutsideOrg(managerId, organizationId) {
|
|
3330
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3331
|
+
if (!requestOrg) return false;
|
|
3332
|
+
let rows = [];
|
|
3333
|
+
try {
|
|
3334
|
+
rows = await this.engine.find("sys_member", {
|
|
3335
|
+
where: { user_id: managerId },
|
|
3336
|
+
fields: ["user_id", "organization_id"],
|
|
3337
|
+
limit: 1e3,
|
|
3338
|
+
context: SYSTEM_CTX2
|
|
3339
|
+
});
|
|
3340
|
+
} catch {
|
|
3341
|
+
return false;
|
|
3342
|
+
}
|
|
3343
|
+
const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
|
|
3344
|
+
if (!orgs.length) return false;
|
|
3345
|
+
if (orgs.includes(requestOrg)) return false;
|
|
3346
|
+
this.logger?.warn?.(
|
|
3347
|
+
`[approvals] #10153: manager '${managerId}' was dropped from the approver slate \u2014 'sys_user.manager_id' points across an organization boundary. He holds membership in ${orgs.length} organization(s), none of them the request's organization '${requestOrg}', so routing this approval to him would put approval authority over the record outside its tenant. Fix the 'manager_id' link, grant him a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3348
|
+
{ managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
|
|
3349
|
+
);
|
|
3350
|
+
return true;
|
|
3351
|
+
}
|
|
2896
3352
|
/**
|
|
2897
3353
|
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
2898
3354
|
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
@@ -5018,8 +5474,70 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5018
5474
|
return { requests: desired.size, inserted, deleted };
|
|
5019
5475
|
}
|
|
5020
5476
|
// ── Read API ─────────────────────────────────────────────────
|
|
5477
|
+
/**
|
|
5478
|
+
* [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
|
|
5479
|
+
* for THIS caller over THIS query's scope?
|
|
5480
|
+
*
|
|
5481
|
+
* `payload_json` is the one searched column whose contents the serve path
|
|
5482
|
+
* masks per reader (`redactPayloads`, #10749). A predicate over it is
|
|
5483
|
+
* evaluated by the driver against the column AT REST — unmasked, before
|
|
5484
|
+
* anything is served — so for a caller whose view of the snapshot is masked,
|
|
5485
|
+
* row membership answers questions about contents that caller may not read.
|
|
5486
|
+
* The other four arms are columns of `sys_approval_request` itself, which
|
|
5487
|
+
* every caller who can see the row reads whole; they are untouched.
|
|
5488
|
+
*
|
|
5489
|
+
* ## The invariant this method exists to hold
|
|
5490
|
+
*
|
|
5491
|
+
* "This caller's view is masked" is read from **the same authority and the
|
|
5492
|
+
* same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
|
|
5493
|
+
* asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
|
|
5494
|
+
* notion of "redacted" — comparing the readable set against the object's
|
|
5495
|
+
* schema, say — would be a fresh source of drift, and drift between the
|
|
5496
|
+
* serve rule and the filter rule IS the defect this closes, reconstituted one
|
|
5497
|
+
* layer down. So the only "not masked" answer accepted here is the one serve
|
|
5498
|
+
* itself acts on: `undefined`, the seam's documented do-not-narrow branch.
|
|
5499
|
+
* When the seam holds a concrete list the mask is IN FORCE, whether or not it
|
|
5500
|
+
* happens to remove a key from any particular row — a row-dependent question
|
|
5501
|
+
* no predicate can answer before rows exist.
|
|
5502
|
+
*
|
|
5503
|
+
* ## The two predicate-time cases
|
|
5504
|
+
*
|
|
5505
|
+
* Redaction is decided per ROW (each row names its own subject object, hence
|
|
5506
|
+
* its own readable set), but a filter is built before any row exists:
|
|
5507
|
+
*
|
|
5508
|
+
* - **authority absent** — `resolveReadableSnapshotFields` answers
|
|
5509
|
+
* `undefined` for EVERY object, so serve hands over every snapshot whole.
|
|
5510
|
+
* Keeping the arm leaks nothing serve does not already hand over, and this
|
|
5511
|
+
* is the shape every deployment that has not wired the security plugin
|
|
5512
|
+
* gets: search is byte-for-byte unchanged. Checked first, and that order
|
|
5513
|
+
* is load-bearing — see the object-scope note below.
|
|
5514
|
+
* - **authority wired** — the readable set is per object, so the arm is
|
|
5515
|
+
* admissible only for a scope of exactly one KNOWN object. With
|
|
5516
|
+
* `filter.object` present that object is known at predicate time and the
|
|
5517
|
+
* seam is asked about it directly. Absent, the query spans every object
|
|
5518
|
+
* and there is nothing sound to ask, so the arm is dropped.
|
|
5519
|
+
*
|
|
5520
|
+
* Dropping an arm is strictly NARROWING: it never refuses a query and never
|
|
5521
|
+
* widens what comes back, so the fail-closed direction is cheap here and is
|
|
5522
|
+
* taken rather than reaching for a per-object predicate machine to avoid it.
|
|
5523
|
+
* Refusing the query outright would be the louder behaviour change, and is
|
|
5524
|
+
* deliberately not what this does.
|
|
5525
|
+
*/
|
|
5526
|
+
async freeTextMayMatchSnapshot(objectInScope, context) {
|
|
5527
|
+
const authority = this.fieldVisibility;
|
|
5528
|
+
if (!authority || typeof authority.getReadableFields !== "function") return true;
|
|
5529
|
+
const object = String(objectInScope ?? "").trim();
|
|
5530
|
+
if (!object) return false;
|
|
5531
|
+
const readable = await resolveReadableSnapshotFields(
|
|
5532
|
+
authority,
|
|
5533
|
+
object,
|
|
5534
|
+
context,
|
|
5535
|
+
this.logger
|
|
5536
|
+
);
|
|
5537
|
+
return readable === void 0;
|
|
5538
|
+
}
|
|
5021
5539
|
/** Filter type accepted by {@link listRequests} / {@link countRequests}. */
|
|
5022
|
-
buildRequestWhere(filter, context) {
|
|
5540
|
+
async buildRequestWhere(filter, context) {
|
|
5023
5541
|
const f = {};
|
|
5024
5542
|
if (filter?.object) f.object_name = filter.object;
|
|
5025
5543
|
if (filter?.recordId) f.record_id = filter.recordId;
|
|
@@ -5028,13 +5546,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5028
5546
|
if (tenantOrg) f.organization_id = tenantOrg;
|
|
5029
5547
|
const q = filter?.q?.trim();
|
|
5030
5548
|
if (q) {
|
|
5031
|
-
|
|
5549
|
+
const arms = [
|
|
5032
5550
|
{ process_name: { $contains: q } },
|
|
5033
5551
|
{ object_name: { $contains: q } },
|
|
5034
5552
|
{ record_id: { $contains: q } },
|
|
5035
|
-
{ submitter_id: { $contains: q } }
|
|
5036
|
-
{ payload_json: { $contains: q } }
|
|
5553
|
+
{ submitter_id: { $contains: q } }
|
|
5037
5554
|
];
|
|
5555
|
+
if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
|
|
5556
|
+
arms.push({ payload_json: { $contains: q } });
|
|
5557
|
+
}
|
|
5558
|
+
f.$or = arms;
|
|
5038
5559
|
}
|
|
5039
5560
|
if (Array.isArray(filter?.status)) {
|
|
5040
5561
|
const statuses = filter.status.filter(Boolean);
|
|
@@ -5095,7 +5616,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5095
5616
|
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
5096
5617
|
* request from someone who could actually act on it.
|
|
5097
5618
|
*/
|
|
5098
|
-
async visibleRequestIds(context, tenantOrg) {
|
|
5619
|
+
async visibleRequestIds(context, tenantOrg, target) {
|
|
5099
5620
|
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
5100
5621
|
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
5101
5622
|
if (!uid2) return /* @__PURE__ */ new Set();
|
|
@@ -5137,8 +5658,90 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5137
5658
|
error: err instanceof Error ? err.message : String(err)
|
|
5138
5659
|
});
|
|
5139
5660
|
}
|
|
5661
|
+
await this.addRecordReaderVisibleIds(ids, context, tenantOrg, target);
|
|
5140
5662
|
return ids;
|
|
5141
5663
|
}
|
|
5664
|
+
/**
|
|
5665
|
+
* [#8652] Read-only approval visibility derived from READ ACCESS TO THE
|
|
5666
|
+
* TARGET BUSINESS RECORD.
|
|
5667
|
+
*
|
|
5668
|
+
* Maintainer ruling 2026-08-15: a user who can read the target record may
|
|
5669
|
+
* view that record's approval requests and full action history, read-only,
|
|
5670
|
+
* behind a switch that is default OFF, anchored on the EXISTING record-read
|
|
5671
|
+
* permission. The rejected alternative was a host-injected visibility hook —
|
|
5672
|
+
* a security predicate the platform could neither constrain nor audit.
|
|
5673
|
+
*
|
|
5674
|
+
* ## How the anchor is evaluated
|
|
5675
|
+
*
|
|
5676
|
+
* By asking the engine to read the record AS THE CALLER. That is the whole
|
|
5677
|
+
* check: `engine.find(object, { where: { id }, context })` runs the ordinary
|
|
5678
|
+
* ObjectQL middleware — object CRUD read, then RLS — so a denial throws and a
|
|
5679
|
+
* row the caller may not see comes back empty. Both mean "no". No new
|
|
5680
|
+
* permission, role or grant type is invented, and no second copy of the
|
|
5681
|
+
* access rule exists to drift from the first.
|
|
5682
|
+
*
|
|
5683
|
+
* ⚠️ The caller's context is load-bearing. Probing with {@link SYSTEM_CTX} —
|
|
5684
|
+
* the context every other read in this service uses — would read exactly like
|
|
5685
|
+
* a permission check while admitting every authenticated user in the tenant.
|
|
5686
|
+
*
|
|
5687
|
+
* ## Why it needs a NAMED TARGET, and what that deliberately excludes
|
|
5688
|
+
*
|
|
5689
|
+
* The rule is anchored on one record, so it can only be evaluated where a
|
|
5690
|
+
* record is named: a list filtered by `object` + `recordId` (what a record
|
|
5691
|
+
* page's approval tab sends), or a request loaded by id (whose own row names
|
|
5692
|
+
* its target). An UNTARGETED list — the inbox — is left exactly as it was:
|
|
5693
|
+
* answering it under this tier would mean probing every request in the tenant
|
|
5694
|
+
* for read access, which is unbounded, and would turn a work queue into a
|
|
5695
|
+
* browse surface. The confirmed consumer is the record page; the inbox is not
|
|
5696
|
+
* part of the ruling and is not widened here.
|
|
5697
|
+
*
|
|
5698
|
+
* ## What becomes visible (stated plainly, because the switch is an opt-in)
|
|
5699
|
+
*
|
|
5700
|
+
* The request row — including its `payload` snapshot of the record at
|
|
5701
|
+
* submission time — plus the full action history: actor, decision, timestamp,
|
|
5702
|
+
* the action's COMMENT text, and (through the same gate, via
|
|
5703
|
+
* {@link ApprovalService.authorizeFileRead}) any decision attachments. The
|
|
5704
|
+
* comment text is the ruling's "full action history" read literally; it is
|
|
5705
|
+
* flagged on the card as the one granularity edge worth a second look.
|
|
5706
|
+
*
|
|
5707
|
+
* Read-only is not enforced here and must not be: the decision paths
|
|
5708
|
+
* (`decideNode` / `reassign` / `recall` / `comment`) authorize on the pending
|
|
5709
|
+
* approver slate, the submitter, or {@link ApprovalService.isOverrideActor},
|
|
5710
|
+
* none of which this tier touches. Seeing a request confers nothing.
|
|
5711
|
+
*/
|
|
5712
|
+
async addRecordReaderVisibleIds(ids, context, tenantOrg, target) {
|
|
5713
|
+
if (this.recordReaderVisibleObjects.size === 0) return;
|
|
5714
|
+
const object = String(target?.object ?? "").trim();
|
|
5715
|
+
const recordId = String(target?.recordId ?? "").trim();
|
|
5716
|
+
if (!object || !recordId) return;
|
|
5717
|
+
if (!this.recordReaderVisibleObjects.has(object)) return;
|
|
5718
|
+
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
5719
|
+
if (!uid2) return;
|
|
5720
|
+
try {
|
|
5721
|
+
const readable = await this.engine.find(object, {
|
|
5722
|
+
where: { id: recordId },
|
|
5723
|
+
fields: ["id"],
|
|
5724
|
+
limit: 1,
|
|
5725
|
+
context
|
|
5726
|
+
});
|
|
5727
|
+
if (!Array.isArray(readable) || readable.length === 0) return;
|
|
5728
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
5729
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
5730
|
+
where: { object_name: object, record_id: recordId, ...orgWhere },
|
|
5731
|
+
fields: ["id"],
|
|
5732
|
+
limit: _ApprovalService.APPROVER_INDEX_CAP,
|
|
5733
|
+
context: SYSTEM_CTX2
|
|
5734
|
+
});
|
|
5735
|
+
for (const r of Array.isArray(rows) ? rows : []) {
|
|
5736
|
+
if (r?.id != null) ids.add(String(r.id));
|
|
5737
|
+
}
|
|
5738
|
+
} catch (err) {
|
|
5739
|
+
this.logger?.debug?.("[approvals] record-reader visibility probe declined", {
|
|
5740
|
+
object,
|
|
5741
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5742
|
+
});
|
|
5743
|
+
}
|
|
5744
|
+
}
|
|
5142
5745
|
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
5143
5746
|
applyVisibility(where, visible) {
|
|
5144
5747
|
if (!visible) return true;
|
|
@@ -5155,14 +5758,17 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5155
5758
|
return true;
|
|
5156
5759
|
}
|
|
5157
5760
|
async listRequests(filter, context) {
|
|
5158
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
5761
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5159
5762
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5160
5763
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5161
5764
|
if (ids) {
|
|
5162
5765
|
if (ids.length === 0) return [];
|
|
5163
5766
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
5164
5767
|
}
|
|
5165
|
-
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg
|
|
5768
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
|
|
5769
|
+
object: filter?.object,
|
|
5770
|
+
recordId: filter?.recordId
|
|
5771
|
+
}))) return [];
|
|
5166
5772
|
const findOpts = {
|
|
5167
5773
|
where,
|
|
5168
5774
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
@@ -5176,19 +5782,23 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5176
5782
|
}
|
|
5177
5783
|
const rows = await this.engine.find("sys_approval_request", findOpts);
|
|
5178
5784
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
5785
|
+
await this.redactPayloads(list, context);
|
|
5179
5786
|
await this.enrichRows(list);
|
|
5180
5787
|
this.attachViewers(list, context);
|
|
5181
5788
|
return list;
|
|
5182
5789
|
}
|
|
5183
5790
|
async countRequests(filter, context) {
|
|
5184
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
5791
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5185
5792
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5186
5793
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5187
5794
|
if (ids) {
|
|
5188
5795
|
if (ids.length === 0) return 0;
|
|
5189
5796
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
5190
5797
|
}
|
|
5191
|
-
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg
|
|
5798
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
|
|
5799
|
+
object: filter?.object,
|
|
5800
|
+
recordId: filter?.recordId
|
|
5801
|
+
}))) return 0;
|
|
5192
5802
|
const countFn = this.engine.count;
|
|
5193
5803
|
if (typeof countFn === "function") {
|
|
5194
5804
|
try {
|
|
@@ -5234,10 +5844,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5234
5844
|
});
|
|
5235
5845
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
5236
5846
|
if (enforceVisibility) {
|
|
5237
|
-
const visible = await this.visibleRequestIds(context, tenantOrg ?? null
|
|
5847
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null, {
|
|
5848
|
+
object: rows[0].object_name,
|
|
5849
|
+
recordId: rows[0].record_id
|
|
5850
|
+
});
|
|
5238
5851
|
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
5239
5852
|
}
|
|
5240
5853
|
const row = rowFromRequest(rows[0]);
|
|
5854
|
+
await this.redactPayloads([row], context);
|
|
5241
5855
|
await this.enrichRows([row]);
|
|
5242
5856
|
await this.attachFlowSteps(row);
|
|
5243
5857
|
await this.attachDecisionProgress(row, rows[0]);
|
|
@@ -5741,6 +6355,52 @@ function unbindAllHooks(engine) {
|
|
|
5741
6355
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
5742
6356
|
}
|
|
5743
6357
|
|
|
6358
|
+
// src/payload-redaction-middleware.ts
|
|
6359
|
+
var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
|
|
6360
|
+
function parseSnapshot(raw) {
|
|
6361
|
+
if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
|
|
6362
|
+
try {
|
|
6363
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
6364
|
+
} catch {
|
|
6365
|
+
return { ok: false, value: void 0 };
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
async function redactRowsInPlace(rows, security, context, logger) {
|
|
6369
|
+
const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
|
|
6370
|
+
if (list.length === 0) return;
|
|
6371
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6372
|
+
for (const row of list) {
|
|
6373
|
+
if (!row || typeof row !== "object") continue;
|
|
6374
|
+
const raw = row.payload_json;
|
|
6375
|
+
const parsed = parseSnapshot(raw);
|
|
6376
|
+
if (!parsed.ok) continue;
|
|
6377
|
+
const object = String(row.object_name ?? "").trim();
|
|
6378
|
+
if (!object) continue;
|
|
6379
|
+
if (!cache.has(object)) {
|
|
6380
|
+
cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
|
|
6381
|
+
}
|
|
6382
|
+
const readable = cache.get(object);
|
|
6383
|
+
if (readable === void 0) continue;
|
|
6384
|
+
const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
|
|
6385
|
+
if (redactedKeys.length === 0) continue;
|
|
6386
|
+
row.payload_json = JSON.stringify(payload);
|
|
6387
|
+
}
|
|
6388
|
+
}
|
|
6389
|
+
function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
|
|
6390
|
+
engine.registerMiddleware(async (opCtx, next) => {
|
|
6391
|
+
await next();
|
|
6392
|
+
if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
|
|
6393
|
+
if (opCtx?.context?.isSystem) return;
|
|
6394
|
+
try {
|
|
6395
|
+
await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
|
|
6396
|
+
} catch (err) {
|
|
6397
|
+
logger?.warn?.("[approvals] snapshot redaction middleware failed", {
|
|
6398
|
+
error: err?.message ?? String(err)
|
|
6399
|
+
});
|
|
6400
|
+
}
|
|
6401
|
+
}, { object: APPROVAL_REQUEST_OBJECT });
|
|
6402
|
+
}
|
|
6403
|
+
|
|
5744
6404
|
// src/approval-node.ts
|
|
5745
6405
|
import {
|
|
5746
6406
|
defineActionDescriptor as defineActionDescriptor2,
|
|
@@ -5994,6 +6654,9 @@ var ApprovalsServicePlugin = class {
|
|
|
5994
6654
|
engine,
|
|
5995
6655
|
logger: ctx.logger,
|
|
5996
6656
|
publicBaseUrl: this.options.publicBaseUrl,
|
|
6657
|
+
// [#8652] Read-only record-reader visibility. Default OFF — an absent
|
|
6658
|
+
// declaration reaches the service as an empty set and changes nothing.
|
|
6659
|
+
recordReaderVisibleObjects: this.options.recordReaderVisibleObjects,
|
|
5997
6660
|
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
5998
6661
|
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
5999
6662
|
// service resolves its posture during its own start, which may not have
|
|
@@ -6009,11 +6672,26 @@ var ApprovalsServicePlugin = class {
|
|
|
6009
6672
|
}
|
|
6010
6673
|
}
|
|
6011
6674
|
});
|
|
6675
|
+
const fieldVisibility = () => {
|
|
6676
|
+
try {
|
|
6677
|
+
const sec = ctx.getService("security");
|
|
6678
|
+
return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
|
|
6679
|
+
} catch {
|
|
6680
|
+
return void 0;
|
|
6681
|
+
}
|
|
6682
|
+
};
|
|
6683
|
+
this.service.attachFieldVisibility({
|
|
6684
|
+
getReadableFields: (object, context) => {
|
|
6685
|
+
const sec = fieldVisibility();
|
|
6686
|
+
return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
|
|
6687
|
+
}
|
|
6688
|
+
});
|
|
6012
6689
|
if (!this.options.disableAutoHooks) {
|
|
6013
6690
|
try {
|
|
6014
6691
|
unbindAllHooks(engine);
|
|
6015
6692
|
bindApprovalLockHook(engine, ctx.logger);
|
|
6016
6693
|
bindDelegationWriteGuard(engine, ctx.logger);
|
|
6694
|
+
bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
|
|
6017
6695
|
} catch (err) {
|
|
6018
6696
|
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
6019
6697
|
}
|
|
@@ -6053,6 +6731,7 @@ var ApprovalsServicePlugin = class {
|
|
|
6053
6731
|
}
|
|
6054
6732
|
};
|
|
6055
6733
|
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
6734
|
+
this.jobService = jobs;
|
|
6056
6735
|
this.escalationJobScheduled = true;
|
|
6057
6736
|
void sweep().catch((err) => {
|
|
6058
6737
|
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
@@ -6139,14 +6818,32 @@ var ApprovalsServicePlugin = class {
|
|
|
6139
6818
|
);
|
|
6140
6819
|
}
|
|
6141
6820
|
}
|
|
6142
|
-
|
|
6821
|
+
/**
|
|
6822
|
+
* The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
|
|
6823
|
+
* ONLY teardown entry point `ObjectKernel.performShutdown()` and
|
|
6824
|
+
* `LiteKernel.destroy()` invoke.
|
|
6825
|
+
*
|
|
6826
|
+
* [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
|
|
6827
|
+
* `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
|
|
6828
|
+
* past this plugin at shutdown: the SLA escalation job stayed scheduled and
|
|
6829
|
+
* this plugin's ObjectQL hooks stayed bound to an engine the kernel had
|
|
6830
|
+
* finished with. `start()` IS on the interface, so the pair read as symmetric
|
|
6831
|
+
* in review — that asymmetry is what let the same shape survive in six
|
|
6832
|
+
* packages at once.
|
|
6833
|
+
*
|
|
6834
|
+
* This member owns no timer of its own (the escalation clock belongs to
|
|
6835
|
+
* `service-job`), so it never cost a merge-queue eviction the way the
|
|
6836
|
+
* `plugin-reports` / `service-messaging` members did (#9371). The class is
|
|
6837
|
+
* the same one either way: a teardown the kernel does not reach.
|
|
6838
|
+
*/
|
|
6839
|
+
async destroy() {
|
|
6143
6840
|
if (this.escalationJobScheduled) {
|
|
6144
6841
|
try {
|
|
6145
|
-
|
|
6146
|
-
await jobs?.cancel?.(ESCALATION_JOB_NAME);
|
|
6842
|
+
await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
|
|
6147
6843
|
} catch {
|
|
6148
6844
|
}
|
|
6149
6845
|
this.escalationJobScheduled = false;
|
|
6846
|
+
this.jobService = void 0;
|
|
6150
6847
|
}
|
|
6151
6848
|
if (this.engine) {
|
|
6152
6849
|
try {
|
|
@@ -6155,6 +6852,17 @@ var ApprovalsServicePlugin = class {
|
|
|
6155
6852
|
}
|
|
6156
6853
|
}
|
|
6157
6854
|
}
|
|
6855
|
+
/**
|
|
6856
|
+
* Retained alias for {@link destroy}. Kept because it is public API of an
|
|
6857
|
+
* exported class, and removing it would break an embedder who learned to call
|
|
6858
|
+
* it directly precisely BECAUSE the kernel never did. The parameter is now
|
|
6859
|
+
* optional and ignored: `destroy()` takes no context, so teardown uses the
|
|
6860
|
+
* job service captured when the escalation clock was wired. Prefer kernel
|
|
6861
|
+
* shutdown; direct callers keep working unchanged.
|
|
6862
|
+
*/
|
|
6863
|
+
async stop(_ctx) {
|
|
6864
|
+
await this.destroy();
|
|
6865
|
+
}
|
|
6158
6866
|
};
|
|
6159
6867
|
export {
|
|
6160
6868
|
APPROVAL_REVISE_CORRELATION_PREFIX,
|