@objectstack/plugin-approvals 17.0.0-rc.6 → 17.1.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 +2904 -0
- package/dist/index.d.mts +3488 -1072
- package/dist/index.d.ts +3488 -1072
- package/dist/index.js +267 -47
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +269 -49
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -12
package/dist/index.js
CHANGED
|
@@ -140,7 +140,7 @@ var init_en_objects_generated = __esm({
|
|
|
140
140
|
},
|
|
141
141
|
approval_reject: {
|
|
142
142
|
label: "Reject",
|
|
143
|
-
|
|
143
|
+
description: "Reject this request? A rejection is final for every approver.",
|
|
144
144
|
successMessage: "Rejected.",
|
|
145
145
|
params: {
|
|
146
146
|
comment: {
|
|
@@ -193,7 +193,7 @@ var init_en_objects_generated = __esm({
|
|
|
193
193
|
},
|
|
194
194
|
approval_recall: {
|
|
195
195
|
label: "Recall",
|
|
196
|
-
|
|
196
|
+
description: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
197
197
|
successMessage: "Recalled.",
|
|
198
198
|
params: {
|
|
199
199
|
comment: {
|
|
@@ -237,18 +237,18 @@ var init_en_objects_generated = __esm({
|
|
|
237
237
|
action: {
|
|
238
238
|
label: "Action",
|
|
239
239
|
options: {
|
|
240
|
-
submit: "
|
|
241
|
-
approve: "
|
|
242
|
-
reject: "
|
|
243
|
-
recall: "
|
|
244
|
-
escalate: "
|
|
245
|
-
reassign: "
|
|
246
|
-
remind: "
|
|
247
|
-
request_info: "
|
|
248
|
-
comment: "
|
|
249
|
-
revise: "
|
|
250
|
-
resubmit: "
|
|
251
|
-
ooo_substitute: "
|
|
240
|
+
submit: "Submit",
|
|
241
|
+
approve: "Approve",
|
|
242
|
+
reject: "Reject",
|
|
243
|
+
recall: "Recall",
|
|
244
|
+
escalate: "Escalate",
|
|
245
|
+
reassign: "Reassign",
|
|
246
|
+
remind: "Remind",
|
|
247
|
+
request_info: "Request Info",
|
|
248
|
+
comment: "Comment",
|
|
249
|
+
revise: "Revise",
|
|
250
|
+
resubmit: "Resubmit",
|
|
251
|
+
ooo_substitute: "Out-of-Office Substitution"
|
|
252
252
|
}
|
|
253
253
|
},
|
|
254
254
|
actor_id: {
|
|
@@ -461,7 +461,7 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
461
461
|
},
|
|
462
462
|
approval_reject: {
|
|
463
463
|
label: "\u62D2\u7EDD",
|
|
464
|
-
|
|
464
|
+
description: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
|
|
465
465
|
successMessage: "\u5DF2\u62D2\u7EDD\u3002",
|
|
466
466
|
params: {
|
|
467
467
|
comment: {
|
|
@@ -514,7 +514,7 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
514
514
|
},
|
|
515
515
|
approval_recall: {
|
|
516
516
|
label: "\u64A4\u56DE",
|
|
517
|
-
|
|
517
|
+
description: "\u64A4\u56DE\u8BE5\u8BF7\u6C42\uFF1F\u64A4\u56DE\u540E\u5BA1\u6279\u4EBA\u5C06\u65E0\u6CD5\u7EE7\u7EED\u5904\u7406\uFF0C\u8BB0\u5F55\u968F\u5373\u89E3\u9501\u3002",
|
|
518
518
|
successMessage: "\u5DF2\u64A4\u56DE\u3002",
|
|
519
519
|
params: {
|
|
520
520
|
comment: {
|
|
@@ -782,7 +782,7 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
782
782
|
},
|
|
783
783
|
approval_reject: {
|
|
784
784
|
label: "\u5374\u4E0B",
|
|
785
|
-
|
|
785
|
+
description: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u5374\u4E0B\u3057\u307E\u3059\u304B\uFF1F\u5374\u4E0B\u306F\u3059\u3079\u3066\u306E\u627F\u8A8D\u8005\u306B\u5BFE\u3057\u3066\u6700\u7D42\u6C7A\u5B9A\u306B\u306A\u308A\u307E\u3059\u3002",
|
|
786
786
|
successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
|
|
787
787
|
params: {
|
|
788
788
|
comment: {
|
|
@@ -835,7 +835,7 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
835
835
|
},
|
|
836
836
|
approval_recall: {
|
|
837
837
|
label: "\u53D6\u308A\u4E0B\u3052",
|
|
838
|
-
|
|
838
|
+
description: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u53D6\u308A\u4E0B\u3052\u307E\u3059\u304B\uFF1F\u627F\u8A8D\u8005\u306F\u64CD\u4F5C\u3067\u304D\u306A\u304F\u306A\u308A\u3001\u30EC\u30B3\u30FC\u30C9\u306E\u30ED\u30C3\u30AF\u304C\u89E3\u9664\u3055\u308C\u307E\u3059\u3002",
|
|
839
839
|
successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
|
|
840
840
|
params: {
|
|
841
841
|
comment: {
|
|
@@ -1103,7 +1103,7 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1103
1103
|
},
|
|
1104
1104
|
approval_reject: {
|
|
1105
1105
|
label: "Rechazar",
|
|
1106
|
-
|
|
1106
|
+
description: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
|
|
1107
1107
|
successMessage: "Rechazada.",
|
|
1108
1108
|
params: {
|
|
1109
1109
|
comment: {
|
|
@@ -1156,7 +1156,7 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1156
1156
|
},
|
|
1157
1157
|
approval_recall: {
|
|
1158
1158
|
label: "Retirar",
|
|
1159
|
-
|
|
1159
|
+
description: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
|
|
1160
1160
|
successMessage: "Retirada.",
|
|
1161
1161
|
params: {
|
|
1162
1162
|
comment: {
|
|
@@ -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({
|
|
@@ -1447,10 +1482,13 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1447
1482
|
group: "Target"
|
|
1448
1483
|
}),
|
|
1449
1484
|
status: import_data.Field.select(
|
|
1450
|
-
//
|
|
1485
|
+
// Derived from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
|
|
1451
1486
|
// where the list and the reason for each entry live; `ApprovalStatus` is
|
|
1452
|
-
// derived from it, so this column and the contract cannot disagree.
|
|
1453
|
-
|
|
1487
|
+
// derived from it, so this column and the contract cannot disagree. The
|
|
1488
|
+
// authored English label per entry lives beside it in
|
|
1489
|
+
// `APPROVAL_STATUS_LABELS` (#8543) — mapped here, never re-typed, so the
|
|
1490
|
+
// `en` bundle regenerates from the contract's own text.
|
|
1491
|
+
import_contracts.APPROVAL_STATUSES.map((value) => ({ value, label: import_contracts.APPROVAL_STATUS_LABELS[value] })),
|
|
1454
1492
|
{
|
|
1455
1493
|
label: "Status",
|
|
1456
1494
|
required: true,
|
|
@@ -1555,6 +1593,28 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1555
1593
|
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
1556
1594
|
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1557
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.
|
|
1558
1618
|
actions: [
|
|
1559
1619
|
{
|
|
1560
1620
|
name: "approval_approve",
|
|
@@ -1574,7 +1634,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1574
1634
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1575
1635
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1576
1636
|
],
|
|
1577
|
-
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",
|
|
1578
1638
|
locations: ["record_section", "list_item"],
|
|
1579
1639
|
successMessage: "Approved.",
|
|
1580
1640
|
refreshAfter: true
|
|
@@ -1582,6 +1642,15 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1582
1642
|
{
|
|
1583
1643
|
name: "approval_reject",
|
|
1584
1644
|
label: "Reject",
|
|
1645
|
+
// The confirm question lives HERE, not in `confirmText`: this action
|
|
1646
|
+
// collects params, so the console would otherwise chain a confirm dialog
|
|
1647
|
+
// and then the param dialog for one decision — and the first one already
|
|
1648
|
+
// reads as "the action ran" (#7278, maintainer ruling 2026-08-10). The
|
|
1649
|
+
// param dialog renders this as its description, and nothing is POSTed
|
|
1650
|
+
// until its own Confirm: one condition, one wording, one dialog.
|
|
1651
|
+
// NB: the top-level action `description` (#7367), never `ai.description`
|
|
1652
|
+
// — that one is the LLM-facing tool contract and is not shown to anyone.
|
|
1653
|
+
description: "Reject this request? A rejection is final for every approver.",
|
|
1585
1654
|
icon: "x-circle",
|
|
1586
1655
|
// Destructive decision — rendered in the console's danger styling so it
|
|
1587
1656
|
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
@@ -1593,8 +1662,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1593
1662
|
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1594
1663
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1595
1664
|
],
|
|
1596
|
-
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1597
|
-
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
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",
|
|
1598
1666
|
locations: ["record_section", "list_item"],
|
|
1599
1667
|
successMessage: "Rejected.",
|
|
1600
1668
|
refreshAfter: true
|
|
@@ -1615,7 +1683,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1615
1683
|
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1616
1684
|
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1617
1685
|
],
|
|
1618
|
-
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",
|
|
1619
1687
|
locations: ["record_section"],
|
|
1620
1688
|
successMessage: "Reassigned.",
|
|
1621
1689
|
refreshAfter: true
|
|
@@ -1634,7 +1702,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1634
1702
|
params: [
|
|
1635
1703
|
{ name: "comment", label: "Reason", type: "textarea", required: false }
|
|
1636
1704
|
],
|
|
1637
|
-
visible: "record.viewer.can_act",
|
|
1705
|
+
visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
|
|
1638
1706
|
locations: ["record_section"],
|
|
1639
1707
|
successMessage: "Sent back for revision.",
|
|
1640
1708
|
refreshAfter: true
|
|
@@ -1649,7 +1717,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1649
1717
|
params: [
|
|
1650
1718
|
{ name: "comment", label: "What do you need?", type: "textarea", required: true }
|
|
1651
1719
|
],
|
|
1652
|
-
visible: "record.viewer.can_act",
|
|
1720
|
+
visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
|
|
1653
1721
|
locations: ["record_section"],
|
|
1654
1722
|
successMessage: "Information requested.",
|
|
1655
1723
|
refreshAfter: true
|
|
@@ -1670,7 +1738,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1670
1738
|
params: [
|
|
1671
1739
|
{ name: "comment", label: "Note", type: "textarea", required: false }
|
|
1672
1740
|
],
|
|
1673
|
-
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',
|
|
1674
1742
|
locations: ["record_section"],
|
|
1675
1743
|
successMessage: "Reminder sent.",
|
|
1676
1744
|
refreshAfter: true
|
|
@@ -1678,6 +1746,9 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1678
1746
|
{
|
|
1679
1747
|
name: "approval_recall",
|
|
1680
1748
|
label: "Recall",
|
|
1749
|
+
// Confirm question as the param dialog's description, not `confirmText`
|
|
1750
|
+
// — same one-decision-one-dialog rule as `approval_reject` above (#7278).
|
|
1751
|
+
description: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1681
1752
|
icon: "undo-2",
|
|
1682
1753
|
type: "api",
|
|
1683
1754
|
method: "POST",
|
|
@@ -1687,8 +1758,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1687
1758
|
],
|
|
1688
1759
|
// Recall applies while the request is live for the submitter — pending
|
|
1689
1760
|
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1690
|
-
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
|
|
1691
|
-
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1761
|
+
visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
|
|
1692
1762
|
locations: ["record_section"],
|
|
1693
1763
|
successMessage: "Recalled.",
|
|
1694
1764
|
refreshAfter: true
|
|
@@ -1703,7 +1773,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1703
1773
|
params: [
|
|
1704
1774
|
{ name: "comment", label: "What changed?", type: "textarea", required: false }
|
|
1705
1775
|
],
|
|
1706
|
-
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',
|
|
1707
1777
|
locations: ["record_section"],
|
|
1708
1778
|
successMessage: "Resubmitted.",
|
|
1709
1779
|
refreshAfter: true
|
|
@@ -1798,11 +1868,14 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1798
1868
|
group: "Target"
|
|
1799
1869
|
}),
|
|
1800
1870
|
action: import_data2.Field.select(
|
|
1801
|
-
//
|
|
1871
|
+
// Derived from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
|
|
1802
1872
|
// is where the list and the per-kind notes live (which kinds move the flow
|
|
1803
1873
|
// and which are thread-only); `ApprovalActionKind` is derived from it, so
|
|
1804
|
-
// this column and the contract cannot disagree.
|
|
1805
|
-
|
|
1874
|
+
// this column and the contract cannot disagree. The authored English label
|
|
1875
|
+
// per kind lives beside it in `APPROVAL_ACTION_KIND_LABELS` (#8580 — the
|
|
1876
|
+
// #7232 humanization pass missed this field) — mapped here, never
|
|
1877
|
+
// re-typed, so the `en` bundle regenerates from the contract's own text.
|
|
1878
|
+
import_contracts2.APPROVAL_ACTION_KINDS.map((value) => ({ value, label: import_contracts2.APPROVAL_ACTION_KIND_LABELS[value] })),
|
|
1806
1879
|
{
|
|
1807
1880
|
label: "Action",
|
|
1808
1881
|
required: true,
|
|
@@ -2334,6 +2407,9 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2334
2407
|
this.messaging = opts.messaging;
|
|
2335
2408
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2336
2409
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2410
|
+
this.recordReaderVisibleObjects = new Set(
|
|
2411
|
+
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2412
|
+
);
|
|
2337
2413
|
}
|
|
2338
2414
|
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
2339
2415
|
attachTenancyPosture(provider) {
|
|
@@ -2835,9 +2911,48 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2835
2911
|
* Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
|
|
2836
2912
|
* assignment table, keyed by the position's machine name (ADR-0057 D4),
|
|
2837
2913
|
* unioned with the better-auth membership string (`sys_member.role`) as a
|
|
2838
|
-
* transition source
|
|
2839
|
-
*
|
|
2840
|
-
*
|
|
2914
|
+
* transition source.
|
|
2915
|
+
*
|
|
2916
|
+
* ⚠️ This is a ROUTING read (approver slates and escalation targets), and it
|
|
2917
|
+
* is deliberately NOT the same read as `PositionGraphService` in
|
|
2918
|
+
* `plugin-sharing`, whatever the shared method name suggests. Both answer
|
|
2919
|
+
* "who holds position P"; this one reads the directory RAW — neither the
|
|
2920
|
+
* ADR-0091 D2 validity window nor the `sys_position.active` catalogue flag is
|
|
2921
|
+
* applied. Maintainer ruling, 2026-08-15 (#8710, inheriting #8613), verbatim:
|
|
2922
|
+
*
|
|
2923
|
+
* > Access-conferring paths filter deactivated positions; addressing paths
|
|
2924
|
+
* > do not.
|
|
2925
|
+
*
|
|
2926
|
+
* Routing is an addressing path, so dropping a holder here is fail-OPEN, not
|
|
2927
|
+
* fail-closed: an expansion that comes back empty does not narrow the slate,
|
|
2928
|
+
* it falls through to the literal `position:` slot no user can ever act on —
|
|
2929
|
+
* the permanently stuck request of #3807 / #3424. A step routing to nobody is
|
|
2930
|
+
* worse than one routing to a lapsed holder, so the lapsed holder stays.
|
|
2931
|
+
*
|
|
2932
|
+
* Where the two implementations actually stand, per source. Both limbs are
|
|
2933
|
+
* listed because a statement about one of them is not a statement about this
|
|
2934
|
+
* method:
|
|
2935
|
+
*
|
|
2936
|
+
* 1. `sys_user_position` — sharing projects `valid_from` / `valid_until` and
|
|
2937
|
+
* drops rows on `isGrantActive` inside its own helper; we project
|
|
2938
|
+
* `user_id` alone, so an assignment that expired last month still routes.
|
|
2939
|
+
* This is the one real divergence, and it is the intended one.
|
|
2940
|
+
* 2. `sys_member.role` — raw on BOTH sides (`TeamGraphService.expandRoleUsers`
|
|
2941
|
+
* projects `user_id` too). The table carries no window columns at all and
|
|
2942
|
+
* `isGrantActive` reads an absent bound as unbounded, so there is nothing
|
|
2943
|
+
* a filter could do here; membership tier names have no `sys_position`
|
|
2944
|
+
* row either (#8710's "a name with no row is untouched" fallback), so no
|
|
2945
|
+
* catalogue flag either. This limb cannot be brought into parity by
|
|
2946
|
+
* adding a filter — see {@link expandMembershipTierUsers}.
|
|
2947
|
+
* 3. `sys_position.active` — the sharing engine's gate for it lives at the
|
|
2948
|
+
* RULE EVALUATOR's call site (`positionConfersAccess` in
|
|
2949
|
+
* `sharing-rule-service.ts`), not inside `PositionGraphService`; the same
|
|
2950
|
+
* ruling gives it no counterpart on this path.
|
|
2951
|
+
*
|
|
2952
|
+
* The omission is per-READ, not a missing dependency: `isGrantActive` is
|
|
2953
|
+
* imported in this file and IS applied to `sys_approval_delegation` in
|
|
2954
|
+
* {@link lookupActiveDelegation}. ⛔ So do not "fix" this by adding the window
|
|
2955
|
+
* filter here — that is the option #8710 rejected, on the reasoning above.
|
|
2841
2956
|
*/
|
|
2842
2957
|
async expandPositionUsers(positionName, organizationId) {
|
|
2843
2958
|
if (!positionName) return [];
|
|
@@ -2865,6 +2980,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2865
2980
|
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
2866
2981
|
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
2867
2982
|
* and stays, the platform-facing word does not.
|
|
2983
|
+
*
|
|
2984
|
+
* Read RAW, like every routing read here, and with nothing available to
|
|
2985
|
+
* filter even if it were not: `sys_member` carries no ADR-0091 D2 window
|
|
2986
|
+
* columns, and a tier name has no `sys_position` row to read `active` off.
|
|
2987
|
+
* {@link expandPositionUsers} carries the ruling both reads inherit
|
|
2988
|
+
* (#8613 / #8710) — this method is also the second limb of that union, so a
|
|
2989
|
+
* change here changes position routing too.
|
|
2868
2990
|
*/
|
|
2869
2991
|
async expandMembershipTierUsers(tier, organizationId) {
|
|
2870
2992
|
if (!tier) return [];
|
|
@@ -4401,10 +4523,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4401
4523
|
* class of failure stayed silent.
|
|
4402
4524
|
*
|
|
4403
4525
|
* It also could not have answered the question even if it looked: its
|
|
4404
|
-
* liveness oracle is `getRun`, which
|
|
4405
|
-
*
|
|
4406
|
-
*
|
|
4407
|
-
*
|
|
4526
|
+
* liveness oracle is `getRun`, which treats both `null` and `paused` as alive
|
|
4527
|
+
* (conservative, correct) — so it has no way to say "this run is really
|
|
4528
|
+
* gone". (Until #8050 that oracle was weaker still: after a restart it
|
|
4529
|
+
* returned `null` for a perfectly ALIVE suspended run, so "alive" and
|
|
4530
|
+
* "unknown" were the same answer. It now reports such a run as `paused`,
|
|
4531
|
+
* which does not change any branch here — both already meant "leave alone" —
|
|
4532
|
+
* but it is why the sweep below needs `hasSuspendedRun` as a second oracle
|
|
4533
|
+
* rather than a sharper reading of the first.)
|
|
4408
4534
|
*
|
|
4409
4535
|
* So this uses BOTH oracles, and a row must fail both to be reported:
|
|
4410
4536
|
*
|
|
@@ -5090,7 +5216,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5090
5216
|
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
5091
5217
|
* request from someone who could actually act on it.
|
|
5092
5218
|
*/
|
|
5093
|
-
async visibleRequestIds(context, tenantOrg) {
|
|
5219
|
+
async visibleRequestIds(context, tenantOrg, target) {
|
|
5094
5220
|
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
5095
5221
|
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
5096
5222
|
if (!uid2) return /* @__PURE__ */ new Set();
|
|
@@ -5132,8 +5258,90 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5132
5258
|
error: err instanceof Error ? err.message : String(err)
|
|
5133
5259
|
});
|
|
5134
5260
|
}
|
|
5261
|
+
await this.addRecordReaderVisibleIds(ids, context, tenantOrg, target);
|
|
5135
5262
|
return ids;
|
|
5136
5263
|
}
|
|
5264
|
+
/**
|
|
5265
|
+
* [#8652] Read-only approval visibility derived from READ ACCESS TO THE
|
|
5266
|
+
* TARGET BUSINESS RECORD.
|
|
5267
|
+
*
|
|
5268
|
+
* Maintainer ruling 2026-08-15: a user who can read the target record may
|
|
5269
|
+
* view that record's approval requests and full action history, read-only,
|
|
5270
|
+
* behind a switch that is default OFF, anchored on the EXISTING record-read
|
|
5271
|
+
* permission. The rejected alternative was a host-injected visibility hook —
|
|
5272
|
+
* a security predicate the platform could neither constrain nor audit.
|
|
5273
|
+
*
|
|
5274
|
+
* ## How the anchor is evaluated
|
|
5275
|
+
*
|
|
5276
|
+
* By asking the engine to read the record AS THE CALLER. That is the whole
|
|
5277
|
+
* check: `engine.find(object, { where: { id }, context })` runs the ordinary
|
|
5278
|
+
* ObjectQL middleware — object CRUD read, then RLS — so a denial throws and a
|
|
5279
|
+
* row the caller may not see comes back empty. Both mean "no". No new
|
|
5280
|
+
* permission, role or grant type is invented, and no second copy of the
|
|
5281
|
+
* access rule exists to drift from the first.
|
|
5282
|
+
*
|
|
5283
|
+
* ⚠️ The caller's context is load-bearing. Probing with {@link SYSTEM_CTX} —
|
|
5284
|
+
* the context every other read in this service uses — would read exactly like
|
|
5285
|
+
* a permission check while admitting every authenticated user in the tenant.
|
|
5286
|
+
*
|
|
5287
|
+
* ## Why it needs a NAMED TARGET, and what that deliberately excludes
|
|
5288
|
+
*
|
|
5289
|
+
* The rule is anchored on one record, so it can only be evaluated where a
|
|
5290
|
+
* record is named: a list filtered by `object` + `recordId` (what a record
|
|
5291
|
+
* page's approval tab sends), or a request loaded by id (whose own row names
|
|
5292
|
+
* its target). An UNTARGETED list — the inbox — is left exactly as it was:
|
|
5293
|
+
* answering it under this tier would mean probing every request in the tenant
|
|
5294
|
+
* for read access, which is unbounded, and would turn a work queue into a
|
|
5295
|
+
* browse surface. The confirmed consumer is the record page; the inbox is not
|
|
5296
|
+
* part of the ruling and is not widened here.
|
|
5297
|
+
*
|
|
5298
|
+
* ## What becomes visible (stated plainly, because the switch is an opt-in)
|
|
5299
|
+
*
|
|
5300
|
+
* The request row — including its `payload` snapshot of the record at
|
|
5301
|
+
* submission time — plus the full action history: actor, decision, timestamp,
|
|
5302
|
+
* the action's COMMENT text, and (through the same gate, via
|
|
5303
|
+
* {@link ApprovalService.authorizeFileRead}) any decision attachments. The
|
|
5304
|
+
* comment text is the ruling's "full action history" read literally; it is
|
|
5305
|
+
* flagged on the card as the one granularity edge worth a second look.
|
|
5306
|
+
*
|
|
5307
|
+
* Read-only is not enforced here and must not be: the decision paths
|
|
5308
|
+
* (`decideNode` / `reassign` / `recall` / `comment`) authorize on the pending
|
|
5309
|
+
* approver slate, the submitter, or {@link ApprovalService.isOverrideActor},
|
|
5310
|
+
* none of which this tier touches. Seeing a request confers nothing.
|
|
5311
|
+
*/
|
|
5312
|
+
async addRecordReaderVisibleIds(ids, context, tenantOrg, target) {
|
|
5313
|
+
if (this.recordReaderVisibleObjects.size === 0) return;
|
|
5314
|
+
const object = String(target?.object ?? "").trim();
|
|
5315
|
+
const recordId = String(target?.recordId ?? "").trim();
|
|
5316
|
+
if (!object || !recordId) return;
|
|
5317
|
+
if (!this.recordReaderVisibleObjects.has(object)) return;
|
|
5318
|
+
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
5319
|
+
if (!uid2) return;
|
|
5320
|
+
try {
|
|
5321
|
+
const readable = await this.engine.find(object, {
|
|
5322
|
+
where: { id: recordId },
|
|
5323
|
+
fields: ["id"],
|
|
5324
|
+
limit: 1,
|
|
5325
|
+
context
|
|
5326
|
+
});
|
|
5327
|
+
if (!Array.isArray(readable) || readable.length === 0) return;
|
|
5328
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
5329
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
5330
|
+
where: { object_name: object, record_id: recordId, ...orgWhere },
|
|
5331
|
+
fields: ["id"],
|
|
5332
|
+
limit: _ApprovalService.APPROVER_INDEX_CAP,
|
|
5333
|
+
context: SYSTEM_CTX2
|
|
5334
|
+
});
|
|
5335
|
+
for (const r of Array.isArray(rows) ? rows : []) {
|
|
5336
|
+
if (r?.id != null) ids.add(String(r.id));
|
|
5337
|
+
}
|
|
5338
|
+
} catch (err) {
|
|
5339
|
+
this.logger?.debug?.("[approvals] record-reader visibility probe declined", {
|
|
5340
|
+
object,
|
|
5341
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5342
|
+
});
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5137
5345
|
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
5138
5346
|
applyVisibility(where, visible) {
|
|
5139
5347
|
if (!visible) return true;
|
|
@@ -5157,7 +5365,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5157
5365
|
if (ids.length === 0) return [];
|
|
5158
5366
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
5159
5367
|
}
|
|
5160
|
-
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg
|
|
5368
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
|
|
5369
|
+
object: filter?.object,
|
|
5370
|
+
recordId: filter?.recordId
|
|
5371
|
+
}))) return [];
|
|
5161
5372
|
const findOpts = {
|
|
5162
5373
|
where,
|
|
5163
5374
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
@@ -5183,7 +5394,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5183
5394
|
if (ids.length === 0) return 0;
|
|
5184
5395
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
5185
5396
|
}
|
|
5186
|
-
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg
|
|
5397
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
|
|
5398
|
+
object: filter?.object,
|
|
5399
|
+
recordId: filter?.recordId
|
|
5400
|
+
}))) return 0;
|
|
5187
5401
|
const countFn = this.engine.count;
|
|
5188
5402
|
if (typeof countFn === "function") {
|
|
5189
5403
|
try {
|
|
@@ -5229,7 +5443,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5229
5443
|
});
|
|
5230
5444
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
5231
5445
|
if (enforceVisibility) {
|
|
5232
|
-
const visible = await this.visibleRequestIds(context, tenantOrg ?? null
|
|
5446
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null, {
|
|
5447
|
+
object: rows[0].object_name,
|
|
5448
|
+
recordId: rows[0].record_id
|
|
5449
|
+
});
|
|
5233
5450
|
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
5234
5451
|
}
|
|
5235
5452
|
const row = rowFromRequest(rows[0]);
|
|
@@ -5981,6 +6198,9 @@ var ApprovalsServicePlugin = class {
|
|
|
5981
6198
|
engine,
|
|
5982
6199
|
logger: ctx.logger,
|
|
5983
6200
|
publicBaseUrl: this.options.publicBaseUrl,
|
|
6201
|
+
// [#8652] Read-only record-reader visibility. Default OFF — an absent
|
|
6202
|
+
// declaration reaches the service as an empty set and changes nothing.
|
|
6203
|
+
recordReaderVisibleObjects: this.options.recordReaderVisibleObjects,
|
|
5984
6204
|
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
5985
6205
|
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
5986
6206
|
// service resolves its posture during its own start, which may not have
|