@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.mjs
CHANGED
|
@@ -128,7 +128,7 @@ var init_en_objects_generated = __esm({
|
|
|
128
128
|
},
|
|
129
129
|
approval_reject: {
|
|
130
130
|
label: "Reject",
|
|
131
|
-
|
|
131
|
+
description: "Reject this request? A rejection is final for every approver.",
|
|
132
132
|
successMessage: "Rejected.",
|
|
133
133
|
params: {
|
|
134
134
|
comment: {
|
|
@@ -181,7 +181,7 @@ var init_en_objects_generated = __esm({
|
|
|
181
181
|
},
|
|
182
182
|
approval_recall: {
|
|
183
183
|
label: "Recall",
|
|
184
|
-
|
|
184
|
+
description: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
185
185
|
successMessage: "Recalled.",
|
|
186
186
|
params: {
|
|
187
187
|
comment: {
|
|
@@ -225,18 +225,18 @@ var init_en_objects_generated = __esm({
|
|
|
225
225
|
action: {
|
|
226
226
|
label: "Action",
|
|
227
227
|
options: {
|
|
228
|
-
submit: "
|
|
229
|
-
approve: "
|
|
230
|
-
reject: "
|
|
231
|
-
recall: "
|
|
232
|
-
escalate: "
|
|
233
|
-
reassign: "
|
|
234
|
-
remind: "
|
|
235
|
-
request_info: "
|
|
236
|
-
comment: "
|
|
237
|
-
revise: "
|
|
238
|
-
resubmit: "
|
|
239
|
-
ooo_substitute: "
|
|
228
|
+
submit: "Submit",
|
|
229
|
+
approve: "Approve",
|
|
230
|
+
reject: "Reject",
|
|
231
|
+
recall: "Recall",
|
|
232
|
+
escalate: "Escalate",
|
|
233
|
+
reassign: "Reassign",
|
|
234
|
+
remind: "Remind",
|
|
235
|
+
request_info: "Request Info",
|
|
236
|
+
comment: "Comment",
|
|
237
|
+
revise: "Revise",
|
|
238
|
+
resubmit: "Resubmit",
|
|
239
|
+
ooo_substitute: "Out-of-Office Substitution"
|
|
240
240
|
}
|
|
241
241
|
},
|
|
242
242
|
actor_id: {
|
|
@@ -449,7 +449,7 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
449
449
|
},
|
|
450
450
|
approval_reject: {
|
|
451
451
|
label: "\u62D2\u7EDD",
|
|
452
|
-
|
|
452
|
+
description: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
|
|
453
453
|
successMessage: "\u5DF2\u62D2\u7EDD\u3002",
|
|
454
454
|
params: {
|
|
455
455
|
comment: {
|
|
@@ -502,7 +502,7 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
502
502
|
},
|
|
503
503
|
approval_recall: {
|
|
504
504
|
label: "\u64A4\u56DE",
|
|
505
|
-
|
|
505
|
+
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",
|
|
506
506
|
successMessage: "\u5DF2\u64A4\u56DE\u3002",
|
|
507
507
|
params: {
|
|
508
508
|
comment: {
|
|
@@ -770,7 +770,7 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
770
770
|
},
|
|
771
771
|
approval_reject: {
|
|
772
772
|
label: "\u5374\u4E0B",
|
|
773
|
-
|
|
773
|
+
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",
|
|
774
774
|
successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
|
|
775
775
|
params: {
|
|
776
776
|
comment: {
|
|
@@ -823,7 +823,7 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
823
823
|
},
|
|
824
824
|
approval_recall: {
|
|
825
825
|
label: "\u53D6\u308A\u4E0B\u3052",
|
|
826
|
-
|
|
826
|
+
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",
|
|
827
827
|
successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
|
|
828
828
|
params: {
|
|
829
829
|
comment: {
|
|
@@ -1091,7 +1091,7 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1091
1091
|
},
|
|
1092
1092
|
approval_reject: {
|
|
1093
1093
|
label: "Rechazar",
|
|
1094
|
-
|
|
1094
|
+
description: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
|
|
1095
1095
|
successMessage: "Rechazada.",
|
|
1096
1096
|
params: {
|
|
1097
1097
|
comment: {
|
|
@@ -1144,7 +1144,7 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1144
1144
|
},
|
|
1145
1145
|
approval_recall: {
|
|
1146
1146
|
label: "Retirar",
|
|
1147
|
-
|
|
1147
|
+
description: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
|
|
1148
1148
|
successMessage: "Retirada.",
|
|
1149
1149
|
params: {
|
|
1150
1150
|
comment: {
|
|
@@ -1321,7 +1321,7 @@ var init_translations = __esm({
|
|
|
1321
1321
|
|
|
1322
1322
|
// src/sys-approval-request.object.ts
|
|
1323
1323
|
import { ObjectSchema, Field } from "@objectstack/spec/data";
|
|
1324
|
-
import { APPROVAL_STATUSES } from "@objectstack/spec/contracts";
|
|
1324
|
+
import { APPROVAL_STATUSES, APPROVAL_STATUS_LABELS } from "@objectstack/spec/contracts";
|
|
1325
1325
|
var SysApprovalRequest = ObjectSchema.create({
|
|
1326
1326
|
name: "sys_approval_request",
|
|
1327
1327
|
label: "Approval Request",
|
|
@@ -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({
|
|
@@ -1420,10 +1455,13 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1420
1455
|
group: "Target"
|
|
1421
1456
|
}),
|
|
1422
1457
|
status: Field.select(
|
|
1423
|
-
//
|
|
1458
|
+
// Derived from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
|
|
1424
1459
|
// where the list and the reason for each entry live; `ApprovalStatus` is
|
|
1425
|
-
// derived from it, so this column and the contract cannot disagree.
|
|
1426
|
-
|
|
1460
|
+
// derived from it, so this column and the contract cannot disagree. The
|
|
1461
|
+
// authored English label per entry lives beside it in
|
|
1462
|
+
// `APPROVAL_STATUS_LABELS` (#8543) — mapped here, never re-typed, so the
|
|
1463
|
+
// `en` bundle regenerates from the contract's own text.
|
|
1464
|
+
APPROVAL_STATUSES.map((value) => ({ value, label: APPROVAL_STATUS_LABELS[value] })),
|
|
1427
1465
|
{
|
|
1428
1466
|
label: "Status",
|
|
1429
1467
|
required: true,
|
|
@@ -1528,6 +1566,28 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1528
1566
|
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
1529
1567
|
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1530
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.
|
|
1531
1591
|
actions: [
|
|
1532
1592
|
{
|
|
1533
1593
|
name: "approval_approve",
|
|
@@ -1547,7 +1607,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1547
1607
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1548
1608
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1549
1609
|
],
|
|
1550
|
-
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",
|
|
1551
1611
|
locations: ["record_section", "list_item"],
|
|
1552
1612
|
successMessage: "Approved.",
|
|
1553
1613
|
refreshAfter: true
|
|
@@ -1555,6 +1615,15 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1555
1615
|
{
|
|
1556
1616
|
name: "approval_reject",
|
|
1557
1617
|
label: "Reject",
|
|
1618
|
+
// The confirm question lives HERE, not in `confirmText`: this action
|
|
1619
|
+
// collects params, so the console would otherwise chain a confirm dialog
|
|
1620
|
+
// and then the param dialog for one decision — and the first one already
|
|
1621
|
+
// reads as "the action ran" (#7278, maintainer ruling 2026-08-10). The
|
|
1622
|
+
// param dialog renders this as its description, and nothing is POSTed
|
|
1623
|
+
// until its own Confirm: one condition, one wording, one dialog.
|
|
1624
|
+
// NB: the top-level action `description` (#7367), never `ai.description`
|
|
1625
|
+
// — that one is the LLM-facing tool contract and is not shown to anyone.
|
|
1626
|
+
description: "Reject this request? A rejection is final for every approver.",
|
|
1558
1627
|
icon: "x-circle",
|
|
1559
1628
|
// Destructive decision — rendered in the console's danger styling so it
|
|
1560
1629
|
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
@@ -1566,8 +1635,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1566
1635
|
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1567
1636
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1568
1637
|
],
|
|
1569
|
-
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1570
|
-
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
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",
|
|
1571
1639
|
locations: ["record_section", "list_item"],
|
|
1572
1640
|
successMessage: "Rejected.",
|
|
1573
1641
|
refreshAfter: true
|
|
@@ -1588,7 +1656,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1588
1656
|
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1589
1657
|
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1590
1658
|
],
|
|
1591
|
-
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",
|
|
1592
1660
|
locations: ["record_section"],
|
|
1593
1661
|
successMessage: "Reassigned.",
|
|
1594
1662
|
refreshAfter: true
|
|
@@ -1607,7 +1675,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1607
1675
|
params: [
|
|
1608
1676
|
{ name: "comment", label: "Reason", type: "textarea", required: false }
|
|
1609
1677
|
],
|
|
1610
|
-
visible: "record.viewer.can_act",
|
|
1678
|
+
visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
|
|
1611
1679
|
locations: ["record_section"],
|
|
1612
1680
|
successMessage: "Sent back for revision.",
|
|
1613
1681
|
refreshAfter: true
|
|
@@ -1622,7 +1690,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1622
1690
|
params: [
|
|
1623
1691
|
{ name: "comment", label: "What do you need?", type: "textarea", required: true }
|
|
1624
1692
|
],
|
|
1625
|
-
visible: "record.viewer.can_act",
|
|
1693
|
+
visible: "has(record.viewer) && has(record.viewer.can_act) && record.viewer.can_act == true",
|
|
1626
1694
|
locations: ["record_section"],
|
|
1627
1695
|
successMessage: "Information requested.",
|
|
1628
1696
|
refreshAfter: true
|
|
@@ -1643,7 +1711,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1643
1711
|
params: [
|
|
1644
1712
|
{ name: "comment", label: "Note", type: "textarea", required: false }
|
|
1645
1713
|
],
|
|
1646
|
-
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',
|
|
1647
1715
|
locations: ["record_section"],
|
|
1648
1716
|
successMessage: "Reminder sent.",
|
|
1649
1717
|
refreshAfter: true
|
|
@@ -1651,6 +1719,9 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1651
1719
|
{
|
|
1652
1720
|
name: "approval_recall",
|
|
1653
1721
|
label: "Recall",
|
|
1722
|
+
// Confirm question as the param dialog's description, not `confirmText`
|
|
1723
|
+
// — same one-decision-one-dialog rule as `approval_reject` above (#7278).
|
|
1724
|
+
description: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1654
1725
|
icon: "undo-2",
|
|
1655
1726
|
type: "api",
|
|
1656
1727
|
method: "POST",
|
|
@@ -1660,8 +1731,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1660
1731
|
],
|
|
1661
1732
|
// Recall applies while the request is live for the submitter — pending
|
|
1662
1733
|
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1663
|
-
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
|
|
1664
|
-
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1734
|
+
visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
|
|
1665
1735
|
locations: ["record_section"],
|
|
1666
1736
|
successMessage: "Recalled.",
|
|
1667
1737
|
refreshAfter: true
|
|
@@ -1676,7 +1746,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1676
1746
|
params: [
|
|
1677
1747
|
{ name: "comment", label: "What changed?", type: "textarea", required: false }
|
|
1678
1748
|
],
|
|
1679
|
-
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',
|
|
1680
1750
|
locations: ["record_section"],
|
|
1681
1751
|
successMessage: "Resubmitted.",
|
|
1682
1752
|
refreshAfter: true
|
|
@@ -1692,7 +1762,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1692
1762
|
|
|
1693
1763
|
// src/sys-approval-action.object.ts
|
|
1694
1764
|
import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
|
|
1695
|
-
import { APPROVAL_ACTION_KINDS } from "@objectstack/spec/contracts";
|
|
1765
|
+
import { APPROVAL_ACTION_KINDS, APPROVAL_ACTION_KIND_LABELS } from "@objectstack/spec/contracts";
|
|
1696
1766
|
var SysApprovalAction = ObjectSchema2.create({
|
|
1697
1767
|
name: "sys_approval_action",
|
|
1698
1768
|
label: "Approval Action",
|
|
@@ -1771,11 +1841,14 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1771
1841
|
group: "Target"
|
|
1772
1842
|
}),
|
|
1773
1843
|
action: Field2.select(
|
|
1774
|
-
//
|
|
1844
|
+
// Derived from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
|
|
1775
1845
|
// is where the list and the per-kind notes live (which kinds move the flow
|
|
1776
1846
|
// and which are thread-only); `ApprovalActionKind` is derived from it, so
|
|
1777
|
-
// this column and the contract cannot disagree.
|
|
1778
|
-
|
|
1847
|
+
// this column and the contract cannot disagree. The authored English label
|
|
1848
|
+
// per kind lives beside it in `APPROVAL_ACTION_KIND_LABELS` (#8580 — the
|
|
1849
|
+
// #7232 humanization pass missed this field) — mapped here, never
|
|
1850
|
+
// re-typed, so the `en` bundle regenerates from the contract's own text.
|
|
1851
|
+
APPROVAL_ACTION_KINDS.map((value) => ({ value, label: APPROVAL_ACTION_KIND_LABELS[value] })),
|
|
1779
1852
|
{
|
|
1780
1853
|
label: "Action",
|
|
1781
1854
|
required: true,
|
|
@@ -2319,6 +2392,9 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2319
2392
|
this.messaging = opts.messaging;
|
|
2320
2393
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2321
2394
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2395
|
+
this.recordReaderVisibleObjects = new Set(
|
|
2396
|
+
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2397
|
+
);
|
|
2322
2398
|
}
|
|
2323
2399
|
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
2324
2400
|
attachTenancyPosture(provider) {
|
|
@@ -2820,9 +2896,48 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2820
2896
|
* Position holders (ADR-0090 D3): `sys_user_position` is the platform-owned
|
|
2821
2897
|
* assignment table, keyed by the position's machine name (ADR-0057 D4),
|
|
2822
2898
|
* unioned with the better-auth membership string (`sys_member.role`) as a
|
|
2823
|
-
* transition source
|
|
2824
|
-
*
|
|
2825
|
-
*
|
|
2899
|
+
* transition source.
|
|
2900
|
+
*
|
|
2901
|
+
* ⚠️ This is a ROUTING read (approver slates and escalation targets), and it
|
|
2902
|
+
* is deliberately NOT the same read as `PositionGraphService` in
|
|
2903
|
+
* `plugin-sharing`, whatever the shared method name suggests. Both answer
|
|
2904
|
+
* "who holds position P"; this one reads the directory RAW — neither the
|
|
2905
|
+
* ADR-0091 D2 validity window nor the `sys_position.active` catalogue flag is
|
|
2906
|
+
* applied. Maintainer ruling, 2026-08-15 (#8710, inheriting #8613), verbatim:
|
|
2907
|
+
*
|
|
2908
|
+
* > Access-conferring paths filter deactivated positions; addressing paths
|
|
2909
|
+
* > do not.
|
|
2910
|
+
*
|
|
2911
|
+
* Routing is an addressing path, so dropping a holder here is fail-OPEN, not
|
|
2912
|
+
* fail-closed: an expansion that comes back empty does not narrow the slate,
|
|
2913
|
+
* it falls through to the literal `position:` slot no user can ever act on —
|
|
2914
|
+
* the permanently stuck request of #3807 / #3424. A step routing to nobody is
|
|
2915
|
+
* worse than one routing to a lapsed holder, so the lapsed holder stays.
|
|
2916
|
+
*
|
|
2917
|
+
* Where the two implementations actually stand, per source. Both limbs are
|
|
2918
|
+
* listed because a statement about one of them is not a statement about this
|
|
2919
|
+
* method:
|
|
2920
|
+
*
|
|
2921
|
+
* 1. `sys_user_position` — sharing projects `valid_from` / `valid_until` and
|
|
2922
|
+
* drops rows on `isGrantActive` inside its own helper; we project
|
|
2923
|
+
* `user_id` alone, so an assignment that expired last month still routes.
|
|
2924
|
+
* This is the one real divergence, and it is the intended one.
|
|
2925
|
+
* 2. `sys_member.role` — raw on BOTH sides (`TeamGraphService.expandRoleUsers`
|
|
2926
|
+
* projects `user_id` too). The table carries no window columns at all and
|
|
2927
|
+
* `isGrantActive` reads an absent bound as unbounded, so there is nothing
|
|
2928
|
+
* a filter could do here; membership tier names have no `sys_position`
|
|
2929
|
+
* row either (#8710's "a name with no row is untouched" fallback), so no
|
|
2930
|
+
* catalogue flag either. This limb cannot be brought into parity by
|
|
2931
|
+
* adding a filter — see {@link expandMembershipTierUsers}.
|
|
2932
|
+
* 3. `sys_position.active` — the sharing engine's gate for it lives at the
|
|
2933
|
+
* RULE EVALUATOR's call site (`positionConfersAccess` in
|
|
2934
|
+
* `sharing-rule-service.ts`), not inside `PositionGraphService`; the same
|
|
2935
|
+
* ruling gives it no counterpart on this path.
|
|
2936
|
+
*
|
|
2937
|
+
* The omission is per-READ, not a missing dependency: `isGrantActive` is
|
|
2938
|
+
* imported in this file and IS applied to `sys_approval_delegation` in
|
|
2939
|
+
* {@link lookupActiveDelegation}. ⛔ So do not "fix" this by adding the window
|
|
2940
|
+
* filter here — that is the option #8710 rejected, on the reasoning above.
|
|
2826
2941
|
*/
|
|
2827
2942
|
async expandPositionUsers(positionName, organizationId) {
|
|
2828
2943
|
if (!positionName) return [];
|
|
@@ -2850,6 +2965,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2850
2965
|
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
2851
2966
|
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
2852
2967
|
* and stays, the platform-facing word does not.
|
|
2968
|
+
*
|
|
2969
|
+
* Read RAW, like every routing read here, and with nothing available to
|
|
2970
|
+
* filter even if it were not: `sys_member` carries no ADR-0091 D2 window
|
|
2971
|
+
* columns, and a tier name has no `sys_position` row to read `active` off.
|
|
2972
|
+
* {@link expandPositionUsers} carries the ruling both reads inherit
|
|
2973
|
+
* (#8613 / #8710) — this method is also the second limb of that union, so a
|
|
2974
|
+
* change here changes position routing too.
|
|
2853
2975
|
*/
|
|
2854
2976
|
async expandMembershipTierUsers(tier, organizationId) {
|
|
2855
2977
|
if (!tier) return [];
|
|
@@ -4386,10 +4508,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4386
4508
|
* class of failure stayed silent.
|
|
4387
4509
|
*
|
|
4388
4510
|
* It also could not have answered the question even if it looked: its
|
|
4389
|
-
* liveness oracle is `getRun`, which
|
|
4390
|
-
*
|
|
4391
|
-
*
|
|
4392
|
-
*
|
|
4511
|
+
* liveness oracle is `getRun`, which treats both `null` and `paused` as alive
|
|
4512
|
+
* (conservative, correct) — so it has no way to say "this run is really
|
|
4513
|
+
* gone". (Until #8050 that oracle was weaker still: after a restart it
|
|
4514
|
+
* returned `null` for a perfectly ALIVE suspended run, so "alive" and
|
|
4515
|
+
* "unknown" were the same answer. It now reports such a run as `paused`,
|
|
4516
|
+
* which does not change any branch here — both already meant "leave alone" —
|
|
4517
|
+
* but it is why the sweep below needs `hasSuspendedRun` as a second oracle
|
|
4518
|
+
* rather than a sharper reading of the first.)
|
|
4393
4519
|
*
|
|
4394
4520
|
* So this uses BOTH oracles, and a row must fail both to be reported:
|
|
4395
4521
|
*
|
|
@@ -5075,7 +5201,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5075
5201
|
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
5076
5202
|
* request from someone who could actually act on it.
|
|
5077
5203
|
*/
|
|
5078
|
-
async visibleRequestIds(context, tenantOrg) {
|
|
5204
|
+
async visibleRequestIds(context, tenantOrg, target) {
|
|
5079
5205
|
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
5080
5206
|
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
5081
5207
|
if (!uid2) return /* @__PURE__ */ new Set();
|
|
@@ -5117,8 +5243,90 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5117
5243
|
error: err instanceof Error ? err.message : String(err)
|
|
5118
5244
|
});
|
|
5119
5245
|
}
|
|
5246
|
+
await this.addRecordReaderVisibleIds(ids, context, tenantOrg, target);
|
|
5120
5247
|
return ids;
|
|
5121
5248
|
}
|
|
5249
|
+
/**
|
|
5250
|
+
* [#8652] Read-only approval visibility derived from READ ACCESS TO THE
|
|
5251
|
+
* TARGET BUSINESS RECORD.
|
|
5252
|
+
*
|
|
5253
|
+
* Maintainer ruling 2026-08-15: a user who can read the target record may
|
|
5254
|
+
* view that record's approval requests and full action history, read-only,
|
|
5255
|
+
* behind a switch that is default OFF, anchored on the EXISTING record-read
|
|
5256
|
+
* permission. The rejected alternative was a host-injected visibility hook —
|
|
5257
|
+
* a security predicate the platform could neither constrain nor audit.
|
|
5258
|
+
*
|
|
5259
|
+
* ## How the anchor is evaluated
|
|
5260
|
+
*
|
|
5261
|
+
* By asking the engine to read the record AS THE CALLER. That is the whole
|
|
5262
|
+
* check: `engine.find(object, { where: { id }, context })` runs the ordinary
|
|
5263
|
+
* ObjectQL middleware — object CRUD read, then RLS — so a denial throws and a
|
|
5264
|
+
* row the caller may not see comes back empty. Both mean "no". No new
|
|
5265
|
+
* permission, role or grant type is invented, and no second copy of the
|
|
5266
|
+
* access rule exists to drift from the first.
|
|
5267
|
+
*
|
|
5268
|
+
* ⚠️ The caller's context is load-bearing. Probing with {@link SYSTEM_CTX} —
|
|
5269
|
+
* the context every other read in this service uses — would read exactly like
|
|
5270
|
+
* a permission check while admitting every authenticated user in the tenant.
|
|
5271
|
+
*
|
|
5272
|
+
* ## Why it needs a NAMED TARGET, and what that deliberately excludes
|
|
5273
|
+
*
|
|
5274
|
+
* The rule is anchored on one record, so it can only be evaluated where a
|
|
5275
|
+
* record is named: a list filtered by `object` + `recordId` (what a record
|
|
5276
|
+
* page's approval tab sends), or a request loaded by id (whose own row names
|
|
5277
|
+
* its target). An UNTARGETED list — the inbox — is left exactly as it was:
|
|
5278
|
+
* answering it under this tier would mean probing every request in the tenant
|
|
5279
|
+
* for read access, which is unbounded, and would turn a work queue into a
|
|
5280
|
+
* browse surface. The confirmed consumer is the record page; the inbox is not
|
|
5281
|
+
* part of the ruling and is not widened here.
|
|
5282
|
+
*
|
|
5283
|
+
* ## What becomes visible (stated plainly, because the switch is an opt-in)
|
|
5284
|
+
*
|
|
5285
|
+
* The request row — including its `payload` snapshot of the record at
|
|
5286
|
+
* submission time — plus the full action history: actor, decision, timestamp,
|
|
5287
|
+
* the action's COMMENT text, and (through the same gate, via
|
|
5288
|
+
* {@link ApprovalService.authorizeFileRead}) any decision attachments. The
|
|
5289
|
+
* comment text is the ruling's "full action history" read literally; it is
|
|
5290
|
+
* flagged on the card as the one granularity edge worth a second look.
|
|
5291
|
+
*
|
|
5292
|
+
* Read-only is not enforced here and must not be: the decision paths
|
|
5293
|
+
* (`decideNode` / `reassign` / `recall` / `comment`) authorize on the pending
|
|
5294
|
+
* approver slate, the submitter, or {@link ApprovalService.isOverrideActor},
|
|
5295
|
+
* none of which this tier touches. Seeing a request confers nothing.
|
|
5296
|
+
*/
|
|
5297
|
+
async addRecordReaderVisibleIds(ids, context, tenantOrg, target) {
|
|
5298
|
+
if (this.recordReaderVisibleObjects.size === 0) return;
|
|
5299
|
+
const object = String(target?.object ?? "").trim();
|
|
5300
|
+
const recordId = String(target?.recordId ?? "").trim();
|
|
5301
|
+
if (!object || !recordId) return;
|
|
5302
|
+
if (!this.recordReaderVisibleObjects.has(object)) return;
|
|
5303
|
+
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
5304
|
+
if (!uid2) return;
|
|
5305
|
+
try {
|
|
5306
|
+
const readable = await this.engine.find(object, {
|
|
5307
|
+
where: { id: recordId },
|
|
5308
|
+
fields: ["id"],
|
|
5309
|
+
limit: 1,
|
|
5310
|
+
context
|
|
5311
|
+
});
|
|
5312
|
+
if (!Array.isArray(readable) || readable.length === 0) return;
|
|
5313
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
5314
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
5315
|
+
where: { object_name: object, record_id: recordId, ...orgWhere },
|
|
5316
|
+
fields: ["id"],
|
|
5317
|
+
limit: _ApprovalService.APPROVER_INDEX_CAP,
|
|
5318
|
+
context: SYSTEM_CTX2
|
|
5319
|
+
});
|
|
5320
|
+
for (const r of Array.isArray(rows) ? rows : []) {
|
|
5321
|
+
if (r?.id != null) ids.add(String(r.id));
|
|
5322
|
+
}
|
|
5323
|
+
} catch (err) {
|
|
5324
|
+
this.logger?.debug?.("[approvals] record-reader visibility probe declined", {
|
|
5325
|
+
object,
|
|
5326
|
+
error: err instanceof Error ? err.message : String(err)
|
|
5327
|
+
});
|
|
5328
|
+
}
|
|
5329
|
+
}
|
|
5122
5330
|
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
5123
5331
|
applyVisibility(where, visible) {
|
|
5124
5332
|
if (!visible) return true;
|
|
@@ -5142,7 +5350,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5142
5350
|
if (ids.length === 0) return [];
|
|
5143
5351
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
5144
5352
|
}
|
|
5145
|
-
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg
|
|
5353
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
|
|
5354
|
+
object: filter?.object,
|
|
5355
|
+
recordId: filter?.recordId
|
|
5356
|
+
}))) return [];
|
|
5146
5357
|
const findOpts = {
|
|
5147
5358
|
where,
|
|
5148
5359
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
@@ -5168,7 +5379,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5168
5379
|
if (ids.length === 0) return 0;
|
|
5169
5380
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
5170
5381
|
}
|
|
5171
|
-
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg
|
|
5382
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg, {
|
|
5383
|
+
object: filter?.object,
|
|
5384
|
+
recordId: filter?.recordId
|
|
5385
|
+
}))) return 0;
|
|
5172
5386
|
const countFn = this.engine.count;
|
|
5173
5387
|
if (typeof countFn === "function") {
|
|
5174
5388
|
try {
|
|
@@ -5214,7 +5428,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5214
5428
|
});
|
|
5215
5429
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
5216
5430
|
if (enforceVisibility) {
|
|
5217
|
-
const visible = await this.visibleRequestIds(context, tenantOrg ?? null
|
|
5431
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null, {
|
|
5432
|
+
object: rows[0].object_name,
|
|
5433
|
+
recordId: rows[0].record_id
|
|
5434
|
+
});
|
|
5218
5435
|
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
5219
5436
|
}
|
|
5220
5437
|
const row = rowFromRequest(rows[0]);
|
|
@@ -5974,6 +6191,9 @@ var ApprovalsServicePlugin = class {
|
|
|
5974
6191
|
engine,
|
|
5975
6192
|
logger: ctx.logger,
|
|
5976
6193
|
publicBaseUrl: this.options.publicBaseUrl,
|
|
6194
|
+
// [#8652] Read-only record-reader visibility. Default OFF — an absent
|
|
6195
|
+
// declaration reaches the service as an empty set and changes nothing.
|
|
6196
|
+
recordReaderVisibleObjects: this.options.recordReaderVisibleObjects,
|
|
5977
6197
|
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
5978
6198
|
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
5979
6199
|
// service resolves its posture during its own start, which may not have
|