@objectstack/plugin-approvals 16.0.0 → 17.0.0-rc.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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +1037 -0
- package/dist/index.d.mts +650 -535
- package/dist/index.d.ts +650 -535
- package/dist/index.js +1713 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1711 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/scripts/i18n-extract.config.ts +6 -1
- package/src/approval-actor-impersonation.test.ts +330 -0
- package/src/approval-node.test.ts +160 -0
- package/src/approval-node.ts +57 -0
- package/src/approval-revise.test.ts +41 -34
- package/src/approval-service.test.ts +1408 -40
- package/src/approval-service.ts +1364 -107
- package/src/approvals-plugin.ts +36 -5
- package/src/approver-cross-org.integration.test.ts +206 -0
- package/src/approver-org-scope.test.ts +201 -0
- package/src/approver-org-scope.ts +261 -0
- package/src/index.ts +3 -0
- package/src/lifecycle-hooks.ts +22 -0
- package/src/record-lock-schedule-run.integration.test.ts +206 -0
- package/src/status-mirror-cascade.integration.test.ts +224 -0
- package/src/sys-approval-action.object.ts +9 -0
- package/src/sys-approval-delegation.object.test.ts +42 -0
- package/src/sys-approval-delegation.object.ts +3 -3
- package/src/sys-approval-request.object.test.ts +13 -0
- package/src/sys-approval-request.object.ts +17 -5
- package/src/translations/bundle-ownership.test.ts +48 -0
- package/src/translations/en.objects.generated.ts +111 -5
- package/src/translations/es-ES.objects.generated.ts +111 -5
- package/src/translations/ja-JP.objects.generated.ts +111 -5
- package/src/translations/zh-CN.objects.generated.ts +110 -4
package/dist/index.mjs
CHANGED
|
@@ -54,7 +54,8 @@ var init_en_objects_generated = __esm({
|
|
|
54
54
|
pending: "pending",
|
|
55
55
|
approved: "approved",
|
|
56
56
|
rejected: "rejected",
|
|
57
|
-
recalled: "recalled"
|
|
57
|
+
recalled: "recalled",
|
|
58
|
+
returned: "returned"
|
|
58
59
|
}
|
|
59
60
|
},
|
|
60
61
|
current_step: {
|
|
@@ -96,7 +97,11 @@ var init_en_objects_generated = __esm({
|
|
|
96
97
|
},
|
|
97
98
|
_views: {
|
|
98
99
|
my_pending: {
|
|
99
|
-
label: "My Pending"
|
|
100
|
+
label: "My Pending",
|
|
101
|
+
emptyState: {
|
|
102
|
+
title: "No pending approvals",
|
|
103
|
+
message: "You're all caught up."
|
|
104
|
+
}
|
|
100
105
|
},
|
|
101
106
|
submitted_by_me: {
|
|
102
107
|
label: "I Submitted"
|
|
@@ -107,6 +112,92 @@ var init_en_objects_generated = __esm({
|
|
|
107
112
|
all_requests: {
|
|
108
113
|
label: "All"
|
|
109
114
|
}
|
|
115
|
+
},
|
|
116
|
+
_actions: {
|
|
117
|
+
approval_approve: {
|
|
118
|
+
label: "Approve",
|
|
119
|
+
successMessage: "Approved.",
|
|
120
|
+
params: {
|
|
121
|
+
comment: {
|
|
122
|
+
label: "Comment"
|
|
123
|
+
},
|
|
124
|
+
attachments: {
|
|
125
|
+
label: "Attachments"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
approval_reject: {
|
|
130
|
+
label: "Reject",
|
|
131
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
132
|
+
successMessage: "Rejected.",
|
|
133
|
+
params: {
|
|
134
|
+
comment: {
|
|
135
|
+
label: "Comment"
|
|
136
|
+
},
|
|
137
|
+
attachments: {
|
|
138
|
+
label: "Attachments"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
approval_reassign: {
|
|
143
|
+
label: "Reassign",
|
|
144
|
+
successMessage: "Reassigned.",
|
|
145
|
+
params: {
|
|
146
|
+
to: {
|
|
147
|
+
label: "New approver",
|
|
148
|
+
helpText: "User to hand this step to"
|
|
149
|
+
},
|
|
150
|
+
comment: {
|
|
151
|
+
label: "Comment"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
approval_send_back: {
|
|
156
|
+
label: "Send back",
|
|
157
|
+
successMessage: "Sent back for revision.",
|
|
158
|
+
params: {
|
|
159
|
+
comment: {
|
|
160
|
+
label: "Reason"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
approval_request_info: {
|
|
165
|
+
label: "Request info",
|
|
166
|
+
successMessage: "Information requested.",
|
|
167
|
+
params: {
|
|
168
|
+
comment: {
|
|
169
|
+
label: "What do you need?"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
approval_remind: {
|
|
174
|
+
label: "Send reminder",
|
|
175
|
+
successMessage: "Reminder sent.",
|
|
176
|
+
params: {
|
|
177
|
+
comment: {
|
|
178
|
+
label: "Note"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
approval_recall: {
|
|
183
|
+
label: "Recall",
|
|
184
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
185
|
+
successMessage: "Recalled.",
|
|
186
|
+
params: {
|
|
187
|
+
comment: {
|
|
188
|
+
label: "Comment"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
approval_resubmit: {
|
|
193
|
+
label: "Resubmit",
|
|
194
|
+
successMessage: "Resubmitted.",
|
|
195
|
+
params: {
|
|
196
|
+
comment: {
|
|
197
|
+
label: "What changed?"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
110
201
|
}
|
|
111
202
|
},
|
|
112
203
|
sys_approval_action: {
|
|
@@ -138,7 +229,14 @@ var init_en_objects_generated = __esm({
|
|
|
138
229
|
approve: "approve",
|
|
139
230
|
reject: "reject",
|
|
140
231
|
recall: "recall",
|
|
141
|
-
escalate: "escalate"
|
|
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: "ooo_substitute"
|
|
142
240
|
}
|
|
143
241
|
},
|
|
144
242
|
actor_id: {
|
|
@@ -147,13 +245,21 @@ var init_en_objects_generated = __esm({
|
|
|
147
245
|
comment: {
|
|
148
246
|
label: "Comment"
|
|
149
247
|
},
|
|
248
|
+
attachments: {
|
|
249
|
+
label: "Attachments",
|
|
250
|
+
help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
251
|
+
},
|
|
150
252
|
created_at: {
|
|
151
253
|
label: "Created At"
|
|
152
254
|
}
|
|
153
255
|
},
|
|
154
256
|
_views: {
|
|
155
257
|
recent: {
|
|
156
|
-
label: "Recent"
|
|
258
|
+
label: "Recent",
|
|
259
|
+
emptyState: {
|
|
260
|
+
title: "No approval actions yet",
|
|
261
|
+
message: "Actions are logged automatically when approvals progress."
|
|
262
|
+
}
|
|
157
263
|
},
|
|
158
264
|
by_actor: {
|
|
159
265
|
label: "By Actor"
|
|
@@ -257,7 +363,8 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
257
363
|
pending: "\u5F85\u5904\u7406",
|
|
258
364
|
approved: "\u5DF2\u6279\u51C6",
|
|
259
365
|
rejected: "\u5DF2\u62D2\u7EDD",
|
|
260
|
-
recalled: "\u5DF2\u64A4\u56DE"
|
|
366
|
+
recalled: "\u5DF2\u64A4\u56DE",
|
|
367
|
+
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
|
|
261
368
|
}
|
|
262
369
|
},
|
|
263
370
|
current_step: {
|
|
@@ -299,7 +406,11 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
299
406
|
},
|
|
300
407
|
_views: {
|
|
301
408
|
my_pending: {
|
|
302
|
-
label: "\u6211\u7684\u5F85\u529E"
|
|
409
|
+
label: "\u6211\u7684\u5F85\u529E",
|
|
410
|
+
emptyState: {
|
|
411
|
+
title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
|
|
412
|
+
message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
|
|
413
|
+
}
|
|
303
414
|
},
|
|
304
415
|
submitted_by_me: {
|
|
305
416
|
label: "\u6211\u63D0\u4EA4\u7684"
|
|
@@ -310,6 +421,92 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
310
421
|
all_requests: {
|
|
311
422
|
label: "\u5168\u90E8"
|
|
312
423
|
}
|
|
424
|
+
},
|
|
425
|
+
_actions: {
|
|
426
|
+
approval_approve: {
|
|
427
|
+
label: "\u901A\u8FC7",
|
|
428
|
+
successMessage: "\u5DF2\u901A\u8FC7\u3002",
|
|
429
|
+
params: {
|
|
430
|
+
comment: {
|
|
431
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
432
|
+
},
|
|
433
|
+
attachments: {
|
|
434
|
+
label: "\u9644\u4EF6"
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
approval_reject: {
|
|
439
|
+
label: "\u62D2\u7EDD",
|
|
440
|
+
confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
|
|
441
|
+
successMessage: "\u5DF2\u62D2\u7EDD\u3002",
|
|
442
|
+
params: {
|
|
443
|
+
comment: {
|
|
444
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
445
|
+
},
|
|
446
|
+
attachments: {
|
|
447
|
+
label: "\u9644\u4EF6"
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
approval_reassign: {
|
|
452
|
+
label: "\u8F6C\u7B7E",
|
|
453
|
+
successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
|
|
454
|
+
params: {
|
|
455
|
+
to: {
|
|
456
|
+
label: "\u65B0\u5BA1\u6279\u4EBA",
|
|
457
|
+
helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
|
|
458
|
+
},
|
|
459
|
+
comment: {
|
|
460
|
+
label: "\u5907\u6CE8"
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
approval_send_back: {
|
|
465
|
+
label: "\u9000\u56DE\u4FEE\u6539",
|
|
466
|
+
successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
|
|
467
|
+
params: {
|
|
468
|
+
comment: {
|
|
469
|
+
label: "\u539F\u56E0"
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
approval_request_info: {
|
|
474
|
+
label: "\u9000\u56DE\u8865\u5145",
|
|
475
|
+
successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
|
|
476
|
+
params: {
|
|
477
|
+
comment: {
|
|
478
|
+
label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
approval_remind: {
|
|
483
|
+
label: "\u50AC\u529E",
|
|
484
|
+
successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
|
|
485
|
+
params: {
|
|
486
|
+
comment: {
|
|
487
|
+
label: "\u5907\u6CE8"
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
approval_recall: {
|
|
492
|
+
label: "\u64A4\u56DE",
|
|
493
|
+
confirmText: "\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",
|
|
494
|
+
successMessage: "\u5DF2\u64A4\u56DE\u3002",
|
|
495
|
+
params: {
|
|
496
|
+
comment: {
|
|
497
|
+
label: "\u5907\u6CE8"
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
approval_resubmit: {
|
|
502
|
+
label: "\u91CD\u65B0\u63D0\u4EA4",
|
|
503
|
+
successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
|
|
504
|
+
params: {
|
|
505
|
+
comment: {
|
|
506
|
+
label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
313
510
|
}
|
|
314
511
|
},
|
|
315
512
|
sys_approval_action: {
|
|
@@ -341,7 +538,14 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
341
538
|
approve: "\u6279\u51C6",
|
|
342
539
|
reject: "\u62D2\u7EDD",
|
|
343
540
|
recall: "\u64A4\u56DE",
|
|
344
|
-
escalate: "\u5347\u7EA7"
|
|
541
|
+
escalate: "\u5347\u7EA7",
|
|
542
|
+
reassign: "\u8F6C\u7B7E",
|
|
543
|
+
remind: "\u50AC\u529E",
|
|
544
|
+
request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
|
|
545
|
+
comment: "\u8BC4\u8BBA",
|
|
546
|
+
revise: "\u9000\u56DE\u4FEE\u6539",
|
|
547
|
+
resubmit: "\u91CD\u65B0\u63D0\u4EA4",
|
|
548
|
+
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
|
|
345
549
|
}
|
|
346
550
|
},
|
|
347
551
|
actor_id: {
|
|
@@ -350,13 +554,21 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
350
554
|
comment: {
|
|
351
555
|
label: "\u8BC4\u8BBA"
|
|
352
556
|
},
|
|
557
|
+
attachments: {
|
|
558
|
+
label: "\u9644\u4EF6",
|
|
559
|
+
help: "\u652F\u6301\u8BE5\u64CD\u4F5C\u7684\u6587\u4EF6\u2014\u2014\u4F8B\u5982\u5DF2\u7B7E\u7F72\u7684\u5408\u540C\u6216\u8BC1\u660E\u6750\u6599\uFF08#3266\uFF09\u3002"
|
|
560
|
+
},
|
|
353
561
|
created_at: {
|
|
354
562
|
label: "\u521B\u5EFA\u65F6\u95F4"
|
|
355
563
|
}
|
|
356
564
|
},
|
|
357
565
|
_views: {
|
|
358
566
|
recent: {
|
|
359
|
-
label: "\u6700\u8FD1"
|
|
567
|
+
label: "\u6700\u8FD1",
|
|
568
|
+
emptyState: {
|
|
569
|
+
title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
|
|
570
|
+
message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
|
|
571
|
+
}
|
|
360
572
|
},
|
|
361
573
|
by_actor: {
|
|
362
574
|
label: "\u6309\u6267\u884C\u4EBA"
|
|
@@ -460,7 +672,8 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
460
672
|
pending: "\u4FDD\u7559\u4E2D",
|
|
461
673
|
approved: "\u627F\u8A8D\u6E08\u307F",
|
|
462
674
|
rejected: "\u5374\u4E0B\u6E08\u307F",
|
|
463
|
-
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
|
|
675
|
+
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
|
|
676
|
+
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
|
|
464
677
|
}
|
|
465
678
|
},
|
|
466
679
|
current_step: {
|
|
@@ -502,7 +715,11 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
502
715
|
},
|
|
503
716
|
_views: {
|
|
504
717
|
my_pending: {
|
|
505
|
-
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
|
|
718
|
+
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
|
|
719
|
+
emptyState: {
|
|
720
|
+
title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
|
|
721
|
+
message: "\u3059\u3079\u3066\u51E6\u7406\u6E08\u307F\u3067\u3059\u3002\u3042\u306A\u305F\u306E\u627F\u8A8D\u3092\u5F85\u3064\u30EA\u30AF\u30A8\u30B9\u30C8\u306F\u3042\u308A\u307E\u305B\u3093\u3002"
|
|
722
|
+
}
|
|
506
723
|
},
|
|
507
724
|
submitted_by_me: {
|
|
508
725
|
label: "\u81EA\u5206\u304C\u9001\u4FE1"
|
|
@@ -513,6 +730,92 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
513
730
|
all_requests: {
|
|
514
731
|
label: "\u3059\u3079\u3066"
|
|
515
732
|
}
|
|
733
|
+
},
|
|
734
|
+
_actions: {
|
|
735
|
+
approval_approve: {
|
|
736
|
+
label: "\u627F\u8A8D",
|
|
737
|
+
successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
|
|
738
|
+
params: {
|
|
739
|
+
comment: {
|
|
740
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
741
|
+
},
|
|
742
|
+
attachments: {
|
|
743
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
approval_reject: {
|
|
748
|
+
label: "\u5374\u4E0B",
|
|
749
|
+
confirmText: "\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",
|
|
750
|
+
successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
|
|
751
|
+
params: {
|
|
752
|
+
comment: {
|
|
753
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
754
|
+
},
|
|
755
|
+
attachments: {
|
|
756
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
approval_reassign: {
|
|
761
|
+
label: "\u518D\u5272\u308A\u5F53\u3066",
|
|
762
|
+
successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
|
|
763
|
+
params: {
|
|
764
|
+
to: {
|
|
765
|
+
label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
|
|
766
|
+
helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
|
|
767
|
+
},
|
|
768
|
+
comment: {
|
|
769
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
approval_send_back: {
|
|
774
|
+
label: "\u5DEE\u3057\u623B\u3057",
|
|
775
|
+
successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
|
|
776
|
+
params: {
|
|
777
|
+
comment: {
|
|
778
|
+
label: "\u7406\u7531"
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
approval_request_info: {
|
|
783
|
+
label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
|
|
784
|
+
successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
|
|
785
|
+
params: {
|
|
786
|
+
comment: {
|
|
787
|
+
label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
approval_remind: {
|
|
792
|
+
label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
|
|
793
|
+
successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
|
|
794
|
+
params: {
|
|
795
|
+
comment: {
|
|
796
|
+
label: "\u30E1\u30E2"
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
approval_recall: {
|
|
801
|
+
label: "\u53D6\u308A\u4E0B\u3052",
|
|
802
|
+
confirmText: "\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",
|
|
803
|
+
successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
|
|
804
|
+
params: {
|
|
805
|
+
comment: {
|
|
806
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
approval_resubmit: {
|
|
811
|
+
label: "\u518D\u63D0\u51FA",
|
|
812
|
+
successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
|
|
813
|
+
params: {
|
|
814
|
+
comment: {
|
|
815
|
+
label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
516
819
|
}
|
|
517
820
|
},
|
|
518
821
|
sys_approval_action: {
|
|
@@ -544,7 +847,14 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
544
847
|
approve: "\u627F\u8A8D",
|
|
545
848
|
reject: "\u5374\u4E0B",
|
|
546
849
|
recall: "\u53D6\u6D88",
|
|
547
|
-
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
|
|
850
|
+
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
|
|
851
|
+
reassign: "\u518D\u5272\u308A\u5F53\u3066",
|
|
852
|
+
remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
|
|
853
|
+
request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
|
|
854
|
+
comment: "\u30B3\u30E1\u30F3\u30C8",
|
|
855
|
+
revise: "\u5DEE\u3057\u623B\u3057",
|
|
856
|
+
resubmit: "\u518D\u63D0\u51FA",
|
|
857
|
+
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
|
|
548
858
|
}
|
|
549
859
|
},
|
|
550
860
|
actor_id: {
|
|
@@ -553,13 +863,21 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
553
863
|
comment: {
|
|
554
864
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
555
865
|
},
|
|
866
|
+
attachments: {
|
|
867
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
|
|
868
|
+
help: "\u3053\u306E\u64CD\u4F5C\u3092\u88CF\u4ED8\u3051\u308B\u30D5\u30A1\u30A4\u30EB\u2014\u2014\u7F72\u540D\u6E08\u307F\u5951\u7D04\u66F8\u3084\u8A3C\u6191\u306A\u3069\uFF08#3266\uFF09\u3002"
|
|
869
|
+
},
|
|
556
870
|
created_at: {
|
|
557
871
|
label: "\u4F5C\u6210\u65E5\u6642"
|
|
558
872
|
}
|
|
559
873
|
},
|
|
560
874
|
_views: {
|
|
561
875
|
recent: {
|
|
562
|
-
label: "\u6700\u8FD1"
|
|
876
|
+
label: "\u6700\u8FD1",
|
|
877
|
+
emptyState: {
|
|
878
|
+
title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
|
|
879
|
+
message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
|
|
880
|
+
}
|
|
563
881
|
},
|
|
564
882
|
by_actor: {
|
|
565
883
|
label: "\u64CD\u4F5C\u8005\u5225"
|
|
@@ -663,7 +981,8 @@ var init_es_ES_objects_generated = __esm({
|
|
|
663
981
|
pending: "Pendiente",
|
|
664
982
|
approved: "Aprobada",
|
|
665
983
|
rejected: "Rechazada",
|
|
666
|
-
recalled: "Retirada"
|
|
984
|
+
recalled: "Retirada",
|
|
985
|
+
returned: "Devuelta para revisi\xF3n"
|
|
667
986
|
}
|
|
668
987
|
},
|
|
669
988
|
current_step: {
|
|
@@ -705,7 +1024,11 @@ var init_es_ES_objects_generated = __esm({
|
|
|
705
1024
|
},
|
|
706
1025
|
_views: {
|
|
707
1026
|
my_pending: {
|
|
708
|
-
label: "Mis pendientes"
|
|
1027
|
+
label: "Mis pendientes",
|
|
1028
|
+
emptyState: {
|
|
1029
|
+
title: "Sin aprobaciones pendientes",
|
|
1030
|
+
message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
|
|
1031
|
+
}
|
|
709
1032
|
},
|
|
710
1033
|
submitted_by_me: {
|
|
711
1034
|
label: "Enviadas por m\xED"
|
|
@@ -716,6 +1039,92 @@ var init_es_ES_objects_generated = __esm({
|
|
|
716
1039
|
all_requests: {
|
|
717
1040
|
label: "Todas"
|
|
718
1041
|
}
|
|
1042
|
+
},
|
|
1043
|
+
_actions: {
|
|
1044
|
+
approval_approve: {
|
|
1045
|
+
label: "Aprobar",
|
|
1046
|
+
successMessage: "Aprobada.",
|
|
1047
|
+
params: {
|
|
1048
|
+
comment: {
|
|
1049
|
+
label: "Comentario"
|
|
1050
|
+
},
|
|
1051
|
+
attachments: {
|
|
1052
|
+
label: "Adjuntos"
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
approval_reject: {
|
|
1057
|
+
label: "Rechazar",
|
|
1058
|
+
confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
|
|
1059
|
+
successMessage: "Rechazada.",
|
|
1060
|
+
params: {
|
|
1061
|
+
comment: {
|
|
1062
|
+
label: "Comentario"
|
|
1063
|
+
},
|
|
1064
|
+
attachments: {
|
|
1065
|
+
label: "Adjuntos"
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
approval_reassign: {
|
|
1070
|
+
label: "Reasignar",
|
|
1071
|
+
successMessage: "Reasignada.",
|
|
1072
|
+
params: {
|
|
1073
|
+
to: {
|
|
1074
|
+
label: "Nuevo aprobador",
|
|
1075
|
+
helpText: "Usuario al que se entrega este paso"
|
|
1076
|
+
},
|
|
1077
|
+
comment: {
|
|
1078
|
+
label: "Comentario"
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
approval_send_back: {
|
|
1083
|
+
label: "Devolver",
|
|
1084
|
+
successMessage: "Devuelta para revisi\xF3n.",
|
|
1085
|
+
params: {
|
|
1086
|
+
comment: {
|
|
1087
|
+
label: "Motivo"
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
approval_request_info: {
|
|
1092
|
+
label: "Pedir informaci\xF3n",
|
|
1093
|
+
successMessage: "Informaci\xF3n solicitada.",
|
|
1094
|
+
params: {
|
|
1095
|
+
comment: {
|
|
1096
|
+
label: "\xBFQu\xE9 necesitas?"
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
},
|
|
1100
|
+
approval_remind: {
|
|
1101
|
+
label: "Enviar recordatorio",
|
|
1102
|
+
successMessage: "Recordatorio enviado.",
|
|
1103
|
+
params: {
|
|
1104
|
+
comment: {
|
|
1105
|
+
label: "Nota"
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
approval_recall: {
|
|
1110
|
+
label: "Retirar",
|
|
1111
|
+
confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
|
|
1112
|
+
successMessage: "Retirada.",
|
|
1113
|
+
params: {
|
|
1114
|
+
comment: {
|
|
1115
|
+
label: "Comentario"
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
approval_resubmit: {
|
|
1120
|
+
label: "Reenviar",
|
|
1121
|
+
successMessage: "Reenviada.",
|
|
1122
|
+
params: {
|
|
1123
|
+
comment: {
|
|
1124
|
+
label: "\xBFQu\xE9 ha cambiado?"
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
719
1128
|
}
|
|
720
1129
|
},
|
|
721
1130
|
sys_approval_action: {
|
|
@@ -747,7 +1156,14 @@ var init_es_ES_objects_generated = __esm({
|
|
|
747
1156
|
approve: "Aprobar",
|
|
748
1157
|
reject: "Rechazar",
|
|
749
1158
|
recall: "Retirar",
|
|
750
|
-
escalate: "Escalar"
|
|
1159
|
+
escalate: "Escalar",
|
|
1160
|
+
reassign: "Reasignaci\xF3n",
|
|
1161
|
+
remind: "Recordatorio",
|
|
1162
|
+
request_info: "Petici\xF3n de informaci\xF3n",
|
|
1163
|
+
comment: "Comentario",
|
|
1164
|
+
revise: "Devoluci\xF3n",
|
|
1165
|
+
resubmit: "Reenv\xEDo",
|
|
1166
|
+
ooo_substitute: "Sustituci\xF3n por ausencia"
|
|
751
1167
|
}
|
|
752
1168
|
},
|
|
753
1169
|
actor_id: {
|
|
@@ -756,13 +1172,21 @@ var init_es_ES_objects_generated = __esm({
|
|
|
756
1172
|
comment: {
|
|
757
1173
|
label: "Comentario"
|
|
758
1174
|
},
|
|
1175
|
+
attachments: {
|
|
1176
|
+
label: "Adjuntos",
|
|
1177
|
+
help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
|
|
1178
|
+
},
|
|
759
1179
|
created_at: {
|
|
760
1180
|
label: "Creado el"
|
|
761
1181
|
}
|
|
762
1182
|
},
|
|
763
1183
|
_views: {
|
|
764
1184
|
recent: {
|
|
765
|
-
label: "Recientes"
|
|
1185
|
+
label: "Recientes",
|
|
1186
|
+
emptyState: {
|
|
1187
|
+
title: "A\xFAn no hay acciones de aprobaci\xF3n",
|
|
1188
|
+
message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
|
|
1189
|
+
}
|
|
766
1190
|
},
|
|
767
1191
|
by_actor: {
|
|
768
1192
|
label: "Por actor"
|
|
@@ -1047,13 +1471,22 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1047
1471
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1048
1472
|
// (the caller is a current pending approver — same check the service
|
|
1049
1473
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1050
|
-
// submitter actions on `record.viewer.is_submitter`.
|
|
1051
|
-
//
|
|
1474
|
+
// submitter actions on `record.viewer.is_submitter`. The core decision levers
|
|
1475
|
+
// (approve/reject/reassign) additionally OR in `record.viewer.can_override`
|
|
1476
|
+
// (#3424) so a platform/tenant admin can rescue a request routed to an
|
|
1477
|
+
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
1478
|
+
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
1479
|
+
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1480
|
+
// closed.
|
|
1052
1481
|
actions: [
|
|
1053
1482
|
{
|
|
1054
1483
|
name: "approval_approve",
|
|
1055
1484
|
label: "Approve",
|
|
1056
1485
|
icon: "check-circle",
|
|
1486
|
+
// Primary decision — the console renders this filled/highlighted so it
|
|
1487
|
+
// stands out from the secondary levers in the drawer's action bar,
|
|
1488
|
+
// matching the mobile card hierarchy (objectui#2762 P1-5).
|
|
1489
|
+
variant: "primary",
|
|
1057
1490
|
type: "api",
|
|
1058
1491
|
method: "POST",
|
|
1059
1492
|
target: "/api/v1/approvals/requests/{id}/approve",
|
|
@@ -1064,7 +1497,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1064
1497
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1065
1498
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1066
1499
|
],
|
|
1067
|
-
visible: "record.viewer.can_act",
|
|
1500
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1068
1501
|
locations: ["record_section", "list_item"],
|
|
1069
1502
|
successMessage: "Approved.",
|
|
1070
1503
|
refreshAfter: true
|
|
@@ -1073,6 +1506,9 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1073
1506
|
name: "approval_reject",
|
|
1074
1507
|
label: "Reject",
|
|
1075
1508
|
icon: "x-circle",
|
|
1509
|
+
// Destructive decision — rendered in the console's danger styling so it
|
|
1510
|
+
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
1511
|
+
variant: "danger",
|
|
1076
1512
|
type: "api",
|
|
1077
1513
|
method: "POST",
|
|
1078
1514
|
target: "/api/v1/approvals/requests/{id}/reject",
|
|
@@ -1080,7 +1516,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1080
1516
|
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1081
1517
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1082
1518
|
],
|
|
1083
|
-
visible: "record.viewer.can_act",
|
|
1519
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1084
1520
|
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1085
1521
|
locations: ["record_section", "list_item"],
|
|
1086
1522
|
successMessage: "Rejected.",
|
|
@@ -1102,7 +1538,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1102
1538
|
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1103
1539
|
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1104
1540
|
],
|
|
1105
|
-
visible: "record.viewer.can_act",
|
|
1541
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1106
1542
|
locations: ["record_section"],
|
|
1107
1543
|
successMessage: "Reassigned.",
|
|
1108
1544
|
refreshAfter: true
|
|
@@ -1219,6 +1655,14 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1219
1655
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1220
1656
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1221
1657
|
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1658
|
+
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1659
|
+
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1660
|
+
// their download by testing whether the caller can READ this row. It cannot:
|
|
1661
|
+
// this table is deliberately closed to ordinary approver positions, so that
|
|
1662
|
+
// test denies the very approver the attachment was filed for. The approvals
|
|
1663
|
+
// service already owns the rule for seeing a decision (visibility of the
|
|
1664
|
+
// parent request, exactly as `listActions` applies it), so it answers.
|
|
1665
|
+
fileAccessDelegate: "approvals",
|
|
1222
1666
|
listViews: {
|
|
1223
1667
|
recent: {
|
|
1224
1668
|
type: "grid",
|
|
@@ -1470,9 +1914,9 @@ var SysApprovalDelegation = ObjectSchema4.create({
|
|
|
1470
1914
|
trackHistory: true,
|
|
1471
1915
|
searchable: true,
|
|
1472
1916
|
apiEnabled: true,
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1917
|
+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
|
|
1918
|
+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
|
|
1919
|
+
apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
|
|
1476
1920
|
}
|
|
1477
1921
|
});
|
|
1478
1922
|
|
|
@@ -1480,16 +1924,167 @@ var SysApprovalDelegation = ObjectSchema4.create({
|
|
|
1480
1924
|
import { createHash, randomBytes } from "crypto";
|
|
1481
1925
|
import {
|
|
1482
1926
|
APPROVAL_BRANCH_LABELS,
|
|
1483
|
-
|
|
1927
|
+
approverTypeIsOrgScoped,
|
|
1928
|
+
canonicalApproverType,
|
|
1929
|
+
normalizeDecisionOutputs
|
|
1484
1930
|
} from "@objectstack/spec/automation";
|
|
1931
|
+
import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
|
|
1932
|
+
import {
|
|
1933
|
+
ADMIN_FULL_ACCESS,
|
|
1934
|
+
ORGANIZATION_ADMIN_GRANTS,
|
|
1935
|
+
BUILTIN_IDENTITY_PLATFORM_ADMIN,
|
|
1936
|
+
BUILTIN_IDENTITY_ORG_OWNER,
|
|
1937
|
+
BUILTIN_IDENTITY_ORG_ADMIN
|
|
1938
|
+
} from "@objectstack/spec/identity";
|
|
1939
|
+
import { RESUME_AUTHORITY_SERVICE } from "@objectstack/spec/contracts";
|
|
1940
|
+
import { isFileIdToken } from "@objectstack/spec/data";
|
|
1485
1941
|
import { isGrantActive } from "@objectstack/core";
|
|
1942
|
+
|
|
1943
|
+
// src/approver-org-scope.ts
|
|
1944
|
+
var MAX_ORG_DEPTH = 32;
|
|
1945
|
+
var SYSTEM_CTX = { isSystem: true };
|
|
1946
|
+
function fail(message) {
|
|
1947
|
+
throw new Error(`VALIDATION_FAILED: ${message}`);
|
|
1948
|
+
}
|
|
1949
|
+
async function findOrg(engine, where) {
|
|
1950
|
+
try {
|
|
1951
|
+
const rows = await engine.find("sys_organization", {
|
|
1952
|
+
filter: where,
|
|
1953
|
+
fields: ["id", "slug", "parent_organization_id"],
|
|
1954
|
+
limit: 1,
|
|
1955
|
+
context: SYSTEM_CTX
|
|
1956
|
+
});
|
|
1957
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
1958
|
+
} catch {
|
|
1959
|
+
return null;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
async function ancestorChain(engine, orgId) {
|
|
1963
|
+
const chain = [];
|
|
1964
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1965
|
+
let cursor = orgId;
|
|
1966
|
+
for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
|
|
1967
|
+
if (seen.has(cursor)) break;
|
|
1968
|
+
seen.add(cursor);
|
|
1969
|
+
chain.push(cursor);
|
|
1970
|
+
const row = await findOrg(engine, { id: cursor });
|
|
1971
|
+
const parent = row?.parent_organization_id;
|
|
1972
|
+
cursor = parent ? String(parent) : null;
|
|
1973
|
+
}
|
|
1974
|
+
return chain;
|
|
1975
|
+
}
|
|
1976
|
+
async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
|
|
1977
|
+
const declared = typeof declaration === "string" ? declaration.trim() : "";
|
|
1978
|
+
if (!declared) return requestOrgId;
|
|
1979
|
+
if (!isOrgScopedType) {
|
|
1980
|
+
fail(
|
|
1981
|
+
`approver type '${approverType}' resolves people without an organization directory, so 'organization: ${declared}' would have no effect \u2014 remove it (ADR-0105 D9 applies to position / org_membership_level / department / expression)`
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
const posture = deps.posture?.();
|
|
1985
|
+
if (posture && posture !== "group") {
|
|
1986
|
+
fail(
|
|
1987
|
+
`cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
1991
|
+
if (!requestOrg) {
|
|
1992
|
+
fail(
|
|
1993
|
+
`'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
const chain = await ancestorChain(deps.engine, requestOrg);
|
|
1997
|
+
if (declared === "$root") {
|
|
1998
|
+
const root = chain[chain.length - 1];
|
|
1999
|
+
if (root === requestOrg && chain.length === 1) {
|
|
2000
|
+
fail(
|
|
2001
|
+
`'organization: $root' resolved to the request's own organization \u2014 this organization has no 'parent_organization_id' lineage. Declare the group hierarchy (ADR-0105 D6) or drop the targeting`
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
return root;
|
|
2005
|
+
}
|
|
2006
|
+
if (declared === "$parent") {
|
|
2007
|
+
const parent = chain[1];
|
|
2008
|
+
if (!parent) {
|
|
2009
|
+
fail(
|
|
2010
|
+
`'organization: $parent' has no parent to resolve to \u2014 the request's organization is already the root of its group (ADR-0105 D6 'parent_organization_id')`
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
return parent;
|
|
2014
|
+
}
|
|
2015
|
+
const target = await findOrg(deps.engine, { slug: declared });
|
|
2016
|
+
if (!target?.id) {
|
|
2017
|
+
fail(
|
|
2018
|
+
`no organization with slug '${declared}' \u2014 'organization' takes an organization SLUG or a symbol ($root / $parent), never an id (ids are per-deployment, flows are portable)`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
const targetId = String(target.id);
|
|
2022
|
+
if (targetId === requestOrg) return targetId;
|
|
2023
|
+
const targetChain = await ancestorChain(deps.engine, targetId);
|
|
2024
|
+
const targetRoot = targetChain[targetChain.length - 1];
|
|
2025
|
+
const requestRoot = chain[chain.length - 1];
|
|
2026
|
+
if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
|
|
2027
|
+
fail(
|
|
2028
|
+
`organization '${declared}' is not in the same group as the request's organization \u2014 cross-organization approval routes WITHIN one group (they must share a 'parent_organization_id' root, ADR-0105 D6/D9)`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
return targetId;
|
|
2032
|
+
}
|
|
2033
|
+
async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
2034
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
2035
|
+
if (!requestOrg || userIds.length === 0) return userIds;
|
|
2036
|
+
let members = [];
|
|
2037
|
+
try {
|
|
2038
|
+
members = await deps.engine.find("sys_member", {
|
|
2039
|
+
filter: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
2040
|
+
fields: ["user_id"],
|
|
2041
|
+
limit: 1e4,
|
|
2042
|
+
context: SYSTEM_CTX
|
|
2043
|
+
});
|
|
2044
|
+
} catch {
|
|
2045
|
+
return userIds;
|
|
2046
|
+
}
|
|
2047
|
+
const canRead = new Set(
|
|
2048
|
+
(members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
|
|
2049
|
+
);
|
|
2050
|
+
const dropped = userIds.filter((u) => !canRead.has(u));
|
|
2051
|
+
if (dropped.length === 0) return userIds;
|
|
2052
|
+
deps.logger?.warn?.(
|
|
2053
|
+
`[approvals] ADR-0105 D9: ${dropped.length} cross-organization approver(s) dropped \u2014 they hold '${context.value ?? context.approverType}' in organization '${context.directoryOrgId}' but no membership in the request's organization '${requestOrg}', so the D2 union wall would hide the request from them. Grant those users a membership in the request's organization, or retarget the approver.`,
|
|
2054
|
+
{ dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
|
|
2055
|
+
);
|
|
2056
|
+
return userIds.filter((u) => canRead.has(u));
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/approval-service.ts
|
|
1486
2060
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1487
2061
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1488
2062
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1489
2063
|
var SLA_ACTOR_ID = "system:sla";
|
|
2064
|
+
var DEAD_RUN_ACTOR_ID = "system:dead-run";
|
|
2065
|
+
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
2066
|
+
"completed",
|
|
2067
|
+
"failed",
|
|
2068
|
+
"cancelled",
|
|
2069
|
+
"timed_out"
|
|
2070
|
+
]);
|
|
1490
2071
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1491
|
-
var
|
|
2072
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2073
|
+
function actingUserId(context) {
|
|
2074
|
+
const userId = context?.userId;
|
|
2075
|
+
return typeof userId === "string" && userId ? userId : null;
|
|
2076
|
+
}
|
|
1492
2077
|
var OOO_MAX_CHAIN = 8;
|
|
2078
|
+
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2079
|
+
"team",
|
|
2080
|
+
"department",
|
|
2081
|
+
"business_unit",
|
|
2082
|
+
"bu",
|
|
2083
|
+
"position",
|
|
2084
|
+
"org_membership_level",
|
|
2085
|
+
"manager"
|
|
2086
|
+
]);
|
|
2087
|
+
var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
|
|
1493
2088
|
function uid(prefix) {
|
|
1494
2089
|
const g = globalThis;
|
|
1495
2090
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1543,7 +2138,26 @@ function rowFromRequest(row) {
|
|
|
1543
2138
|
step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
|
|
1544
2139
|
sla_due_at: slaDueAt(row.created_at, cfg),
|
|
1545
2140
|
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
|
|
1546
|
-
round: typeof cfg?.__round === "number" ? cfg.__round : void 0
|
|
2141
|
+
round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
|
|
2142
|
+
// objectui#2902: the node's record-lock policy. The lock is enforced
|
|
2143
|
+
// server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
|
|
2144
|
+
// same `!== false` default, so the flag a client renders and the rule the
|
|
2145
|
+
// server applies can never drift. Without it a console can only see
|
|
2146
|
+
// "a pending request exists" and has to assume the record is locked —
|
|
2147
|
+
// which mislabels every `lockRecord: false` node as locked and hides an
|
|
2148
|
+
// edit the server would have accepted.
|
|
2149
|
+
lock_record: cfg?.lockRecord !== false,
|
|
2150
|
+
// #3447 P2: the node's author-declared decision outputs, surfaced so a
|
|
2151
|
+
// decision UI can render input fields for them and POST `outputs` on
|
|
2152
|
+
// approve/reject. Per-request (each node declares its own), which is why
|
|
2153
|
+
// this rides the row instead of the static action params. Two shapes for
|
|
2154
|
+
// version skew: `decision_outputs` stays the bare KEY list an older
|
|
2155
|
+
// console renders as text inputs; `decision_output_defs` carries the
|
|
2156
|
+
// normalized typed declarations a picker-aware console prefers.
|
|
2157
|
+
...(() => {
|
|
2158
|
+
const defs = normalizeDecisionOutputs(cfg?.decisionOutputs);
|
|
2159
|
+
return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
|
|
2160
|
+
})()
|
|
1547
2161
|
};
|
|
1548
2162
|
}
|
|
1549
2163
|
function slaDueAt(createdAt, cfg) {
|
|
@@ -1553,7 +2167,29 @@ function slaDueAt(createdAt, cfg) {
|
|
|
1553
2167
|
if (Number.isNaN(t)) return void 0;
|
|
1554
2168
|
return new Date(t + hours * 36e5).toISOString();
|
|
1555
2169
|
}
|
|
2170
|
+
function normalizeActionAttachment(entry) {
|
|
2171
|
+
if (entry == null) return void 0;
|
|
2172
|
+
if (typeof entry === "string") {
|
|
2173
|
+
const id = entry.trim();
|
|
2174
|
+
if (!id) return void 0;
|
|
2175
|
+
return isFileIdToken(id) ? { id } : { id, url: id };
|
|
2176
|
+
}
|
|
2177
|
+
if (typeof entry === "object") {
|
|
2178
|
+
const id = entry.id ?? entry.file_id;
|
|
2179
|
+
if (id == null || String(id) === "") return void 0;
|
|
2180
|
+
const mimeType = entry.mimeType ?? entry.mime_type;
|
|
2181
|
+
return {
|
|
2182
|
+
id: String(id),
|
|
2183
|
+
name: typeof entry.name === "string" ? entry.name : void 0,
|
|
2184
|
+
url: typeof entry.url === "string" ? entry.url : void 0,
|
|
2185
|
+
mimeType: typeof mimeType === "string" ? mimeType : void 0,
|
|
2186
|
+
size: typeof entry.size === "number" ? entry.size : void 0
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
return void 0;
|
|
2190
|
+
}
|
|
1556
2191
|
function rowFromAction(row) {
|
|
2192
|
+
const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
|
|
1557
2193
|
return {
|
|
1558
2194
|
id: String(row.id),
|
|
1559
2195
|
request_id: String(row.request_id),
|
|
@@ -1562,10 +2198,9 @@ function rowFromAction(row) {
|
|
|
1562
2198
|
action: row.action,
|
|
1563
2199
|
actor_id: row.actor_id ?? void 0,
|
|
1564
2200
|
comment: row.comment ?? void 0,
|
|
1565
|
-
// Decision attachments (#3266)
|
|
1566
|
-
//
|
|
1567
|
-
|
|
1568
|
-
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
|
|
2201
|
+
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2202
|
+
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2203
|
+
attachments: attachments.length ? attachments : void 0,
|
|
1569
2204
|
created_at: row.created_at ?? void 0
|
|
1570
2205
|
};
|
|
1571
2206
|
}
|
|
@@ -1577,6 +2212,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1577
2212
|
this.automation = opts.automation;
|
|
1578
2213
|
this.messaging = opts.messaging;
|
|
1579
2214
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2215
|
+
this.tenancyPosture = opts.tenancyPosture;
|
|
2216
|
+
}
|
|
2217
|
+
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
2218
|
+
attachTenancyPosture(provider) {
|
|
2219
|
+
this.tenancyPosture = provider;
|
|
2220
|
+
}
|
|
2221
|
+
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2222
|
+
get orgScopeDeps() {
|
|
2223
|
+
return {
|
|
2224
|
+
engine: this.engine,
|
|
2225
|
+
posture: this.tenancyPosture,
|
|
2226
|
+
logger: this.logger
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
|
|
2231
|
+
* Absent declaration ⇒ the request's own organization (unchanged, no reads).
|
|
2232
|
+
*/
|
|
2233
|
+
async directoryOrgFor(a, requestOrgId) {
|
|
2234
|
+
const rawType = String(a?.type ?? "");
|
|
2235
|
+
return resolveApproverDirectoryOrg(
|
|
2236
|
+
this.orgScopeDeps,
|
|
2237
|
+
a?.organization,
|
|
2238
|
+
requestOrgId,
|
|
2239
|
+
rawType,
|
|
2240
|
+
approverTypeIsOrgScoped(rawType)
|
|
2241
|
+
);
|
|
1580
2242
|
}
|
|
1581
2243
|
/** Attach (or replace) the automation surface used to resume flow runs. */
|
|
1582
2244
|
attachAutomation(automation) {
|
|
@@ -1611,13 +2273,107 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1611
2273
|
const rows = await this.engine.find("sys_approval_request", {
|
|
1612
2274
|
where: { id: requestId },
|
|
1613
2275
|
limit: 1,
|
|
1614
|
-
context:
|
|
2276
|
+
context: SYSTEM_CTX2
|
|
1615
2277
|
});
|
|
1616
2278
|
const raw = Array.isArray(rows) ? rows[0] : null;
|
|
1617
2279
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
1618
2280
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
1619
2281
|
return raw;
|
|
1620
2282
|
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Privileged-override gate (#3424). A stuck approval — one routed to a
|
|
2285
|
+
* position/team with no holders (so its `pending_approvers` is only an
|
|
2286
|
+
* unresolvable `type:value` literal) or to approvers who have all since left —
|
|
2287
|
+
* is otherwise undecidable: no concrete user is in the slate, so every normal
|
|
2288
|
+
* `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
|
|
2289
|
+
* record stays locked forever with no in-product recovery. A platform or
|
|
2290
|
+
* tenant admin — the same posture the engine's superuser bypass already
|
|
2291
|
+
* trusts — may always act on a PENDING request to release it: approve, reject,
|
|
2292
|
+
* reassign it to a real approver, or recall it.
|
|
2293
|
+
*
|
|
2294
|
+
* A platform admin crosses the tenant wall (matching the unscoped
|
|
2295
|
+
* `admin_full_access` evidence); a tenant admin may override only within their
|
|
2296
|
+
* own org (or an org-less request). A system context always passes. Signals are
|
|
2297
|
+
* read defensively off the resolved exec context (`permissions` / `positions` /
|
|
2298
|
+
* the derived `posture`, ADR-0095) so any transport that resolves through the
|
|
2299
|
+
* shared authz resolver lights this up without extra wiring.
|
|
2300
|
+
*/
|
|
2301
|
+
isOverrideActor(context, requestOrg) {
|
|
2302
|
+
if (!context) return false;
|
|
2303
|
+
if (context.isSystem) return true;
|
|
2304
|
+
const perms = Array.isArray(context.permissions) ? context.permissions : [];
|
|
2305
|
+
const positions = Array.isArray(context.positions) ? context.positions : [];
|
|
2306
|
+
const posture = context.posture;
|
|
2307
|
+
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS) || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
2308
|
+
if (isPlatformAdmin) return true;
|
|
2309
|
+
const isTenantAdmin = posture === "TENANT_ADMIN" || ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(BUILTIN_IDENTITY_ORG_ADMIN);
|
|
2310
|
+
if (!isTenantAdmin) return false;
|
|
2311
|
+
const actorTenant = context.tenantId ?? context.organizationId ?? null;
|
|
2312
|
+
return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
2316
|
+
*
|
|
2317
|
+
* Every public entrypoint accepts an `actorId`, and the REST routes fill it
|
|
2318
|
+
* from `body.actorId ?? body.actor_id ?? context.userId` — so before this
|
|
2319
|
+
* gate the body won. The authorization checks downstream all read that value
|
|
2320
|
+
* (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
|
|
2321
|
+
* which made the body-supplied string not merely the audit label but the key
|
|
2322
|
+
* that opens the door: any authenticated user could name a pending approver
|
|
2323
|
+
* and have that approver's decision recorded and the owning flow resumed.
|
|
2324
|
+
* #3783 drew this line for the data-write identity ({@link actingUserId});
|
|
2325
|
+
* this closes the authorization half.
|
|
2326
|
+
*
|
|
2327
|
+
* A caller may still name an identity OTHER than their bare user id, because
|
|
2328
|
+
* a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
|
|
2329
|
+
* `type:value` literal when a graph lookup yields nothing, and an author may
|
|
2330
|
+
* write an email as a `user` approver. So the rule is not "actorId must equal
|
|
2331
|
+
* userId" — it is **"actorId must be an identity the SERVER can prove belongs
|
|
2332
|
+
* to the caller"**. Anything else is `FORBIDDEN`.
|
|
2333
|
+
*
|
|
2334
|
+
* A system context is exempt and keeps its explicit actor: the SLA sweep
|
|
2335
|
+
* passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
|
|
2336
|
+
* link passes the approver its single-use token is cryptographically bound to
|
|
2337
|
+
* (having also put them on the context). Those are the only two callers that
|
|
2338
|
+
* hold a trustworthy actor with no session behind them.
|
|
2339
|
+
*
|
|
2340
|
+
* A caller with NO identity at all cannot act. That case is reachable: the
|
|
2341
|
+
* REST anonymous-deny only fires when `api.requireAuth` is set, so without it
|
|
2342
|
+
* an anonymous request previously decided approvals outright by naming one.
|
|
2343
|
+
*/
|
|
2344
|
+
async resolveActor(actorId, context) {
|
|
2345
|
+
if (context?.isSystem) {
|
|
2346
|
+
if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
2347
|
+
return actorId;
|
|
2348
|
+
}
|
|
2349
|
+
const uid2 = actingUserId(context);
|
|
2350
|
+
if (!uid2) {
|
|
2351
|
+
throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
|
|
2352
|
+
}
|
|
2353
|
+
if (!actorId || String(actorId) === uid2) return uid2;
|
|
2354
|
+
const named = String(actorId);
|
|
2355
|
+
for (const position of context.positions ?? []) {
|
|
2356
|
+
if (named === `position:${position}` || named === `role:${position}`) return named;
|
|
2357
|
+
}
|
|
2358
|
+
if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
|
|
2359
|
+
throw new Error(
|
|
2360
|
+
`FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
/** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
|
|
2364
|
+
async callerHasEmail(userId, email) {
|
|
2365
|
+
try {
|
|
2366
|
+
const rows = await this.engine.find("sys_user", {
|
|
2367
|
+
where: { id: userId },
|
|
2368
|
+
limit: 1,
|
|
2369
|
+
context: SYSTEM_CTX2
|
|
2370
|
+
});
|
|
2371
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2372
|
+
return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
|
|
2373
|
+
} catch {
|
|
2374
|
+
return false;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
1621
2377
|
/**
|
|
1622
2378
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
1623
2379
|
* graph tables for `team:` / `department:` / `position:` /
|
|
@@ -1652,7 +2408,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1652
2408
|
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1653
2409
|
*/
|
|
1654
2410
|
async expandApprovers(step, record, organizationId, opts) {
|
|
1655
|
-
var _a;
|
|
2411
|
+
var _a, _b, _c;
|
|
1656
2412
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1657
2413
|
const now = opts?.now ?? this.clock.now().getTime();
|
|
1658
2414
|
const out = [];
|
|
@@ -1660,12 +2416,34 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1660
2416
|
for (let idx = 0; idx < specs.length; idx++) {
|
|
1661
2417
|
const a = specs[idx];
|
|
1662
2418
|
if (!a) continue;
|
|
1663
|
-
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1664
2419
|
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
2420
|
+
if (canonicalApproverType(String(a.type)) === "expression") {
|
|
2421
|
+
const resolved = await this.resolveExpressionApprovers(
|
|
2422
|
+
a,
|
|
2423
|
+
record,
|
|
2424
|
+
organizationId,
|
|
2425
|
+
now,
|
|
2426
|
+
opts?.substitutions,
|
|
2427
|
+
opts?.exprCtx
|
|
2428
|
+
);
|
|
2429
|
+
if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
|
|
2430
|
+
for (const entry of resolved.slots) {
|
|
2431
|
+
if (!entry.id) continue;
|
|
2432
|
+
out.push(entry.id);
|
|
2433
|
+
if (opts?.groups) {
|
|
2434
|
+
((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
if (opts?.resolvedFrom && canonicalApproverType(String(a.type)) === "field" && a.value != null) {
|
|
2440
|
+
opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
|
|
2441
|
+
}
|
|
2442
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1665
2443
|
for (const u of ids) {
|
|
1666
2444
|
if (!u) continue;
|
|
1667
2445
|
out.push(u);
|
|
1668
|
-
if (opts?.groups) ((
|
|
2446
|
+
if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
|
|
1669
2447
|
}
|
|
1670
2448
|
}
|
|
1671
2449
|
return out.filter(Boolean);
|
|
@@ -1686,24 +2464,35 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1686
2464
|
{ deprecated: a.type, canonical: type }
|
|
1687
2465
|
);
|
|
1688
2466
|
}
|
|
2467
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2468
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
1689
2469
|
if (type === "user") {
|
|
1690
2470
|
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1691
2471
|
}
|
|
1692
2472
|
if (type === "field" && record) {
|
|
1693
|
-
|
|
2473
|
+
const out = [];
|
|
2474
|
+
for (const id of csvSplit(record[a.value])) {
|
|
2475
|
+
out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
|
|
2476
|
+
}
|
|
2477
|
+
return out;
|
|
1694
2478
|
}
|
|
2479
|
+
const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2480
|
+
approverType: type,
|
|
2481
|
+
value: a.value != null ? String(a.value) : void 0,
|
|
2482
|
+
directoryOrgId: directoryOrg
|
|
2483
|
+
}) : users;
|
|
1695
2484
|
try {
|
|
1696
2485
|
if (type === "team") {
|
|
1697
2486
|
const users = await this.expandTeamUsers(String(a.value));
|
|
1698
2487
|
if (users.length) return users;
|
|
1699
2488
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1700
|
-
const users = await this.expandBusinessUnitUsers(String(a.value),
|
|
2489
|
+
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
1701
2490
|
if (users.length) return users;
|
|
1702
2491
|
} else if (type === "position") {
|
|
1703
|
-
const users = await this.expandPositionUsers(String(a.value),
|
|
2492
|
+
const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
|
|
1704
2493
|
if (users.length) return users;
|
|
1705
2494
|
} else if (type === "org_membership_level") {
|
|
1706
|
-
const users = await this.expandMembershipTierUsers(String(a.value),
|
|
2495
|
+
const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
|
|
1707
2496
|
if (users.length) return users;
|
|
1708
2497
|
} else if (type === "manager" && record) {
|
|
1709
2498
|
const subject = record[a.value] ?? record.owner_id;
|
|
@@ -1714,8 +2503,122 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1714
2503
|
}
|
|
1715
2504
|
} catch {
|
|
1716
2505
|
}
|
|
2506
|
+
if (type === "queue") {
|
|
2507
|
+
this.logger?.warn?.(
|
|
2508
|
+
`[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
|
|
2509
|
+
{ value: a.value }
|
|
2510
|
+
);
|
|
2511
|
+
} else if (GRAPH_APPROVER_TYPES.has(type)) {
|
|
2512
|
+
this.logger?.warn?.(
|
|
2513
|
+
`[approvals] approver '${type}:${a.value}' expanded to nobody \u2014 the slot routes to no one and the request cannot advance until someone is added or the approver is re-pointed (#3807)`,
|
|
2514
|
+
{ type, value: a.value, organizationId: organizationId ?? null }
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
1717
2517
|
return [`${a.type}:${a.value}`];
|
|
1718
2518
|
}
|
|
2519
|
+
/**
|
|
2520
|
+
* Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
|
|
2521
|
+
* node entry against the three explicit roots — `current` (live record),
|
|
2522
|
+
* `trigger` (submit snapshot), `vars` (flow variables) — then expand the
|
|
2523
|
+
* result into people per `resolveAs`.
|
|
2524
|
+
*
|
|
2525
|
+
* Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
|
|
2526
|
+
* evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
|
|
2527
|
+
* loudly instead of opening a request routed to nobody — an approver
|
|
2528
|
+
* expression that cannot run is a routing bug, never "condition not met".
|
|
2529
|
+
* Error messages carry the correct spelling because their primary reader is
|
|
2530
|
+
* the AI author fixing the flow on the next validate pass.
|
|
2531
|
+
*
|
|
2532
|
+
* Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
|
|
2533
|
+
* expression's own values, pre-expansion) for the `__resolvedFrom` audit.
|
|
2534
|
+
*/
|
|
2535
|
+
async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
|
|
2536
|
+
const source = String(a.value ?? "").trim();
|
|
2537
|
+
if (!source) {
|
|
2538
|
+
throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
|
|
2539
|
+
}
|
|
2540
|
+
const parsed = collectCelRootIdentifiers(source);
|
|
2541
|
+
if (!parsed.ok) {
|
|
2542
|
+
throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
|
|
2543
|
+
}
|
|
2544
|
+
const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
|
|
2545
|
+
if (illegal.length) {
|
|
2546
|
+
const hint = illegal.includes("record") || illegal.includes("previous") ? `\`record\`/\`previous\` are not bound here \u2014 write \`current.<field>\` for the record's live state at node entry, or \`trigger.<field>\` for the submit-time snapshot (\`vars.previous\` carries the pre-update row)` : `did you mean \`current.<field>\` (live record), \`trigger.<field>\` (submit snapshot), or \`vars.<name>\` (flow variable)?`;
|
|
2547
|
+
throw new Error(
|
|
2548
|
+
`VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
const result = ExpressionEngine.evaluate(
|
|
2552
|
+
{ dialect: "cel", source },
|
|
2553
|
+
{ extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
|
|
2554
|
+
);
|
|
2555
|
+
if (!result.ok) {
|
|
2556
|
+
throw new Error(
|
|
2557
|
+
`EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
|
|
2558
|
+
);
|
|
2559
|
+
}
|
|
2560
|
+
const value = result.value;
|
|
2561
|
+
let raw;
|
|
2562
|
+
if (value == null || value === "") {
|
|
2563
|
+
raw = [];
|
|
2564
|
+
} else if (typeof value === "string") {
|
|
2565
|
+
raw = csvSplit(value);
|
|
2566
|
+
} else if (Array.isArray(value)) {
|
|
2567
|
+
const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
|
|
2568
|
+
if (bad !== void 0) {
|
|
2569
|
+
throw new Error(
|
|
2570
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
|
|
2574
|
+
} else {
|
|
2575
|
+
throw new Error(
|
|
2576
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
const resolveAs = String(a.resolveAs ?? "user");
|
|
2580
|
+
if (resolveAs === "user") {
|
|
2581
|
+
const slots2 = [];
|
|
2582
|
+
for (const id of raw) {
|
|
2583
|
+
for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
|
|
2584
|
+
slots2.push({ id: routed });
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return { slots: slots2, raw };
|
|
2588
|
+
}
|
|
2589
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2590
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
2591
|
+
const slots = [];
|
|
2592
|
+
for (const key of raw) {
|
|
2593
|
+
let users = [];
|
|
2594
|
+
try {
|
|
2595
|
+
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2596
|
+
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2597
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
2598
|
+
else {
|
|
2599
|
+
throw new Error(
|
|
2600
|
+
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
2601
|
+
);
|
|
2602
|
+
}
|
|
2603
|
+
} catch (err) {
|
|
2604
|
+
if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
|
|
2605
|
+
users = [];
|
|
2606
|
+
}
|
|
2607
|
+
if (crossOrg && users.length) {
|
|
2608
|
+
users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2609
|
+
approverType: resolveAs,
|
|
2610
|
+
value: key,
|
|
2611
|
+
directoryOrgId: directoryOrg
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
if (!users.length) {
|
|
2615
|
+
slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
|
|
2616
|
+
continue;
|
|
2617
|
+
}
|
|
2618
|
+
for (const u of users) slots.push({ id: u, subGroup: key });
|
|
2619
|
+
}
|
|
2620
|
+
return { slots, raw };
|
|
2621
|
+
}
|
|
1719
2622
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1720
2623
|
async expandTeamUsers(teamId) {
|
|
1721
2624
|
if (!teamId) return [];
|
|
@@ -1725,22 +2628,46 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1725
2628
|
filter: { team_id: teamId },
|
|
1726
2629
|
fields: ["user_id"],
|
|
1727
2630
|
limit: 1e4,
|
|
1728
|
-
context:
|
|
2631
|
+
context: SYSTEM_CTX2
|
|
1729
2632
|
});
|
|
1730
2633
|
} catch {
|
|
1731
2634
|
rows = [];
|
|
1732
2635
|
}
|
|
1733
2636
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
1734
2637
|
}
|
|
2638
|
+
/**
|
|
2639
|
+
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
2640
|
+
* env-wide (#3807).
|
|
2641
|
+
*
|
|
2642
|
+
* `organization_id = null` on a platform object means "owned by no
|
|
2643
|
+
* organization" — a row written by a seed, the file layer, or bootstrap,
|
|
2644
|
+
* i.e. before (or outside) any org exists. A strict
|
|
2645
|
+
* `organization_id = <request org>` equality made every such row invisible:
|
|
2646
|
+
* the seed check below found nothing, the whole expansion returned `[]`, and
|
|
2647
|
+
* the approver fell back to the dead `department:<id>` literal that routes to
|
|
2648
|
+
* nobody. That is not an edge case — an app's org tree is normally seeded
|
|
2649
|
+
* (a seed cannot know the org id the runtime mints at boot) while the
|
|
2650
|
+
* approval request always carries one, so EVERY department approver a
|
|
2651
|
+
* designer could pick resolved to nobody.
|
|
2652
|
+
*
|
|
2653
|
+
* Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
|
|
2654
|
+
* pending-draft listing settled on for the identical reason. Another org's
|
|
2655
|
+
* unit still fails the match, so the wall between two organizations is
|
|
2656
|
+
* unchanged — only rows belonging to no org at all become visible.
|
|
2657
|
+
*/
|
|
2658
|
+
businessUnitOrgScope(filter, organizationId) {
|
|
2659
|
+
if (!organizationId) return filter;
|
|
2660
|
+
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
|
|
2661
|
+
}
|
|
1735
2662
|
/** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
|
|
1736
2663
|
async expandBusinessUnitUsers(businessUnitId, organizationId) {
|
|
1737
2664
|
if (!businessUnitId) return [];
|
|
1738
2665
|
try {
|
|
1739
2666
|
const seed = await this.engine.find("sys_business_unit", {
|
|
1740
|
-
filter:
|
|
2667
|
+
filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
|
|
1741
2668
|
fields: ["id", "active"],
|
|
1742
2669
|
limit: 1,
|
|
1743
|
-
context:
|
|
2670
|
+
context: SYSTEM_CTX2
|
|
1744
2671
|
});
|
|
1745
2672
|
const seedRow = Array.isArray(seed) ? seed[0] : null;
|
|
1746
2673
|
if (!seedRow || seedRow.active === false) return [];
|
|
@@ -1753,9 +2680,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1753
2680
|
const parent = queue.shift();
|
|
1754
2681
|
let kids = [];
|
|
1755
2682
|
try {
|
|
1756
|
-
const filter =
|
|
1757
|
-
|
|
1758
|
-
|
|
2683
|
+
const filter = this.businessUnitOrgScope(
|
|
2684
|
+
{ parent_business_unit_id: parent, active: { $ne: false } },
|
|
2685
|
+
organizationId
|
|
2686
|
+
);
|
|
2687
|
+
kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
|
|
1759
2688
|
} catch {
|
|
1760
2689
|
kids = [];
|
|
1761
2690
|
}
|
|
@@ -1773,7 +2702,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1773
2702
|
filter: { business_unit_id: { $in: Array.from(seen) } },
|
|
1774
2703
|
fields: ["user_id"],
|
|
1775
2704
|
limit: 1e4,
|
|
1776
|
-
context:
|
|
2705
|
+
context: SYSTEM_CTX2
|
|
1777
2706
|
});
|
|
1778
2707
|
} catch {
|
|
1779
2708
|
rows = [];
|
|
@@ -1798,7 +2727,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1798
2727
|
filter,
|
|
1799
2728
|
fields: ["user_id"],
|
|
1800
2729
|
limit: 1e4,
|
|
1801
|
-
context:
|
|
2730
|
+
context: SYSTEM_CTX2
|
|
1802
2731
|
});
|
|
1803
2732
|
for (const r of rows ?? []) {
|
|
1804
2733
|
const uid2 = String(r.user_id ?? "");
|
|
@@ -1821,7 +2750,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1821
2750
|
if (organizationId) filter.organization_id = organizationId;
|
|
1822
2751
|
let rows = [];
|
|
1823
2752
|
try {
|
|
1824
|
-
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context:
|
|
2753
|
+
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
|
|
1825
2754
|
} catch {
|
|
1826
2755
|
rows = [];
|
|
1827
2756
|
}
|
|
@@ -1833,7 +2762,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1833
2762
|
filter: { id: userId },
|
|
1834
2763
|
fields: ["id", "manager_id"],
|
|
1835
2764
|
limit: 1,
|
|
1836
|
-
context:
|
|
2765
|
+
context: SYSTEM_CTX2
|
|
1837
2766
|
});
|
|
1838
2767
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
1839
2768
|
return row?.manager_id ? String(row.manager_id) : null;
|
|
@@ -1883,7 +2812,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1883
2812
|
filter: { delegator_id: delegatorId },
|
|
1884
2813
|
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1885
2814
|
limit: 50,
|
|
1886
|
-
context:
|
|
2815
|
+
context: SYSTEM_CTX2
|
|
1887
2816
|
});
|
|
1888
2817
|
} catch {
|
|
1889
2818
|
return null;
|
|
@@ -1897,14 +2826,86 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1897
2826
|
});
|
|
1898
2827
|
return active[0];
|
|
1899
2828
|
}
|
|
1900
|
-
/**
|
|
1901
|
-
|
|
2829
|
+
/**
|
|
2830
|
+
* Mirror a request status onto a business-object field, if configured.
|
|
2831
|
+
*
|
|
2832
|
+
* **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
|
|
2833
|
+
* record is normally LOCKED while its approval is live and the submitter
|
|
2834
|
+
* cannot edit it, so only a platform write can land the status — that is what
|
|
2835
|
+
* the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
|
|
2836
|
+
* NOT do is throw away *who* caused the transition. Every status below is
|
|
2837
|
+
* something a specific human just did — a submitter submitting or recalling,
|
|
2838
|
+
* an approver deciding or sending back — and this write is what fires the
|
|
2839
|
+
* target object's record-change flows. With no `userId` on it those cascades
|
|
2840
|
+
* inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
|
|
2841
|
+
* user has its data ops REFUSED — so "when the invoice is approved, do X", the
|
|
2842
|
+
* most natural approvals automation there is, had to declare `runAs:'system'`
|
|
2843
|
+
* and take blanket elevation for a case where a perfectly good scoped identity
|
|
2844
|
+
* existed. Re-attaching the actor lets those cascades run as the deciding user
|
|
2845
|
+
* with RLS enforced. Same shape the approval node already uses when it calls
|
|
2846
|
+
* into this service (`approval-node.ts`).
|
|
2847
|
+
*
|
|
2848
|
+
* `actorId` is `null` for the genuinely machine-driven transitions (the SLA
|
|
2849
|
+
* escalation's auto-decision, the dead-run sweep). There is no human to name
|
|
2850
|
+
* there, and naming a sentinel would put a non-user in `updated_by` and in
|
|
2851
|
+
* every downstream flow's identity. Those cascades stay user-less — a flow
|
|
2852
|
+
* that wants to react to them still has to declare `runAs:'system'`, which is
|
|
2853
|
+
* the honest answer rather than an oversight.
|
|
2854
|
+
*
|
|
2855
|
+
* Deliberately carries `userId` ONLY, not the request's org. On an
|
|
2856
|
+
* ExecutionContext `tenantId` is a driver-scoping knob, not attribution
|
|
2857
|
+
* (`buildDriverOptions` turns it into a tenant predicate on the update), so
|
|
2858
|
+
* passing it would newly org-scope this write and silently no-op the mirror on
|
|
2859
|
+
* a record whose org differs from the request's — while buying nothing: the
|
|
2860
|
+
* automation engine back-fills the run's `tenantId` from the resolved user's
|
|
2861
|
+
* own grants.
|
|
2862
|
+
*/
|
|
2863
|
+
async mirrorStatusField(object, recordId, field, status, actorId) {
|
|
1902
2864
|
try {
|
|
1903
|
-
|
|
2865
|
+
const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
|
|
2866
|
+
await this.engine.update(object, { id: recordId, [field]: status }, { context });
|
|
1904
2867
|
} catch (err) {
|
|
1905
2868
|
this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
|
|
1906
2869
|
}
|
|
1907
2870
|
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Re-read a business record's CURRENT state by id so approver resolution binds
|
|
2873
|
+
* to live data at node entry, not the trigger snapshot the flow froze into
|
|
2874
|
+
* `$record` at submit time (#3447). A `field` / `manager` approver names *who*
|
|
2875
|
+
* decides, and an earlier node — or the approver of an earlier step — may have
|
|
2876
|
+
* written that routing field after submit (e.g. a lead reviewer picking which
|
|
2877
|
+
* departments co-review). Graph approvers (team / position / …) already query
|
|
2878
|
+
* live; this brings the in-record types into line.
|
|
2879
|
+
*
|
|
2880
|
+
* Read under system identity: approver routing is a platform concern and the
|
|
2881
|
+
* record is the flow's own subject, so the submitter's RLS/FLS must not narrow
|
|
2882
|
+
* it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
|
|
2883
|
+
* hard-deleted between submit and node entry, or an object whose backend can't
|
|
2884
|
+
* serve a point read — warning rather than throwing so a transient miss can't
|
|
2885
|
+
* wedge an approval. That "warn but proceed" stance matches the
|
|
2886
|
+
* no-concrete-approver guard (#3424) and the "record is gone" enrichment path
|
|
2887
|
+
* that already falls back to the payload snapshot.
|
|
2888
|
+
*/
|
|
2889
|
+
async loadLiveRecord(object, recordId, fallback) {
|
|
2890
|
+
try {
|
|
2891
|
+
const rows = await this.engine.find(object, {
|
|
2892
|
+
where: { id: recordId },
|
|
2893
|
+
limit: 1,
|
|
2894
|
+
context: SYSTEM_CTX2
|
|
2895
|
+
});
|
|
2896
|
+
const live = Array.isArray(rows) ? rows[0] : rows;
|
|
2897
|
+
if (live) return live;
|
|
2898
|
+
this.logger?.warn?.(
|
|
2899
|
+
`[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
|
|
2900
|
+
{ object, recordId }
|
|
2901
|
+
);
|
|
2902
|
+
} catch (err) {
|
|
2903
|
+
this.logger?.warn?.(
|
|
2904
|
+
`[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
|
|
2905
|
+
);
|
|
2906
|
+
}
|
|
2907
|
+
return fallback ?? {};
|
|
2908
|
+
}
|
|
1908
2909
|
// ── ADR-0019: Approval-as-flow-node ──────────────────────────
|
|
1909
2910
|
//
|
|
1910
2911
|
// A flow's Approval node opens a request via `openNodeRequest` (carrying its
|
|
@@ -1916,6 +2917,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1916
2917
|
* Open a pending approval request on behalf of a flow's Approval node. The
|
|
1917
2918
|
* node config (approvers / behavior / status field) is snapshotted on the row
|
|
1918
2919
|
* so a decision can be made without any process to resolve against.
|
|
2920
|
+
*
|
|
2921
|
+
* #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
|
|
2922
|
+
* request opened — when the slate resolves empty and the node's
|
|
2923
|
+
* `onEmptyApprovers` policy is `auto_approve`.
|
|
1919
2924
|
*/
|
|
1920
2925
|
async openNodeRequest(input, context) {
|
|
1921
2926
|
if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
|
|
@@ -1924,7 +2929,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1924
2929
|
const existing = await this.engine.find("sys_approval_request", {
|
|
1925
2930
|
where: { object_name: input.object, record_id: input.recordId, status: "pending" },
|
|
1926
2931
|
limit: 1,
|
|
1927
|
-
context:
|
|
2932
|
+
context: SYSTEM_CTX2
|
|
1928
2933
|
});
|
|
1929
2934
|
if (Array.isArray(existing) && existing[0]) {
|
|
1930
2935
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
@@ -1933,26 +2938,56 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1933
2938
|
const nowDate = this.clock.now();
|
|
1934
2939
|
const substitutions = [];
|
|
1935
2940
|
const groups = {};
|
|
2941
|
+
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
2942
|
+
const resolvedFrom = {};
|
|
1936
2943
|
const approvers = await this.expandApprovers(
|
|
1937
2944
|
{ approvers: input.config.approvers },
|
|
1938
|
-
|
|
2945
|
+
liveRecord,
|
|
1939
2946
|
ctxOrg,
|
|
1940
|
-
{
|
|
2947
|
+
{
|
|
2948
|
+
now: nowDate.getTime(),
|
|
2949
|
+
substitutions,
|
|
2950
|
+
groups,
|
|
2951
|
+
exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
|
|
2952
|
+
resolvedFrom
|
|
2953
|
+
}
|
|
1941
2954
|
);
|
|
2955
|
+
if (!approvers.some((a) => a && !a.includes(":"))) {
|
|
2956
|
+
const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
|
|
2957
|
+
if (emptyPolicy === "fail") {
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
`NO_APPROVERS: approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver and its onEmptyApprovers policy is 'fail'. Check that the approver target(s) are staffed / the routing field or expression yields user ids at node entry.`
|
|
2960
|
+
);
|
|
2961
|
+
}
|
|
2962
|
+
if (emptyPolicy === "auto_approve") {
|
|
2963
|
+
this.logger?.warn?.(
|
|
2964
|
+
`[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver \u2014 auto-approving per onEmptyApprovers: 'auto_approve' (no request opened).`,
|
|
2965
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
2966
|
+
);
|
|
2967
|
+
return { autoApproved: true, reason: "empty_approvers" };
|
|
2968
|
+
}
|
|
2969
|
+
this.logger?.warn?.(
|
|
2970
|
+
`[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver \u2014 the request is decidable only by a privileged admin. Check that the approver target(s) are staffed.`,
|
|
2971
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
1942
2974
|
const now = nowDate.toISOString();
|
|
1943
2975
|
const id = uid("areq");
|
|
1944
2976
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1945
2977
|
const configSnapshot = { ...input.config };
|
|
1946
2978
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1947
2979
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1948
|
-
if (input.config.behavior
|
|
2980
|
+
if (input.config.behavior && input.config.behavior !== "first_response") {
|
|
1949
2981
|
configSnapshot.__approverGroups = groups;
|
|
1950
2982
|
}
|
|
2983
|
+
if (Object.keys(resolvedFrom).length) {
|
|
2984
|
+
configSnapshot.__resolvedFrom = resolvedFrom;
|
|
2985
|
+
}
|
|
1951
2986
|
try {
|
|
1952
2987
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1953
2988
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
1954
2989
|
limit: 500,
|
|
1955
|
-
context:
|
|
2990
|
+
context: SYSTEM_CTX2
|
|
1956
2991
|
});
|
|
1957
2992
|
const n = Array.isArray(prior) ? prior.length : 0;
|
|
1958
2993
|
if (n > 0) configSnapshot.__round = n + 1;
|
|
@@ -1976,7 +3011,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1976
3011
|
created_at: now,
|
|
1977
3012
|
updated_at: now
|
|
1978
3013
|
};
|
|
1979
|
-
await this.engine.insert("sys_approval_request", row, { context:
|
|
3014
|
+
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
1980
3015
|
await this.syncApproverIndex(id, approvers, ctxOrg, now);
|
|
1981
3016
|
await this.engine.insert("sys_approval_action", {
|
|
1982
3017
|
id: uid("aact"),
|
|
@@ -1988,7 +3023,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1988
3023
|
actor_id: input.submitterId ?? context.userId ?? null,
|
|
1989
3024
|
comment: null,
|
|
1990
3025
|
created_at: now
|
|
1991
|
-
}, { context:
|
|
3026
|
+
}, { context: SYSTEM_CTX2 });
|
|
1992
3027
|
for (const sub of substitutions) {
|
|
1993
3028
|
await this.engine.insert("sys_approval_action", {
|
|
1994
3029
|
id: uid("aact"),
|
|
@@ -2000,7 +3035,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2000
3035
|
actor_id: null,
|
|
2001
3036
|
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
2002
3037
|
created_at: now
|
|
2003
|
-
}, { context:
|
|
3038
|
+
}, { context: SYSTEM_CTX2 });
|
|
2004
3039
|
await this.notify({
|
|
2005
3040
|
topic: "approval.ooo_substituted",
|
|
2006
3041
|
audience: [sub.to],
|
|
@@ -2025,7 +3060,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2025
3060
|
});
|
|
2026
3061
|
}
|
|
2027
3062
|
if (input.config.approvalStatusField) {
|
|
2028
|
-
await this.mirrorStatusField(
|
|
3063
|
+
await this.mirrorStatusField(
|
|
3064
|
+
input.object,
|
|
3065
|
+
input.recordId,
|
|
3066
|
+
input.config.approvalStatusField,
|
|
3067
|
+
"pending",
|
|
3068
|
+
row.submitter_id ?? null
|
|
3069
|
+
);
|
|
2029
3070
|
}
|
|
2030
3071
|
return rowFromRequest(row);
|
|
2031
3072
|
}
|
|
@@ -2069,27 +3110,52 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2069
3110
|
*/
|
|
2070
3111
|
async decideNode(requestId, input, context) {
|
|
2071
3112
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2072
|
-
|
|
3113
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2073
3114
|
if (input.decision !== "approve" && input.decision !== "reject") {
|
|
2074
3115
|
throw new Error("VALIDATION_FAILED: decision must be approve|reject");
|
|
2075
3116
|
}
|
|
2076
3117
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2077
3118
|
where: { id: requestId },
|
|
2078
3119
|
limit: 1,
|
|
2079
|
-
context:
|
|
3120
|
+
context: SYSTEM_CTX2
|
|
2080
3121
|
});
|
|
2081
3122
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2082
3123
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2083
3124
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2084
3125
|
const pendingApprovers = csvSplit(raw.pending_approvers);
|
|
2085
|
-
|
|
2086
|
-
|
|
3126
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3127
|
+
const isSlotHolder = pendingApprovers.includes(actorId);
|
|
3128
|
+
if (!isSlotHolder && !isOverride) {
|
|
3129
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2087
3130
|
}
|
|
2088
3131
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2089
3132
|
const org = raw.organization_id ?? null;
|
|
2090
3133
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
2091
3134
|
const runId = raw.flow_run_id ?? null;
|
|
2092
3135
|
const now = this.clock.now().toISOString();
|
|
3136
|
+
const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
|
|
3137
|
+
let acceptedOutputs;
|
|
3138
|
+
if (outputKeys.length) {
|
|
3139
|
+
const declared = normalizeDecisionOutputs(config.decisionOutputs).map((d) => d.key);
|
|
3140
|
+
if (!declared.length) {
|
|
3141
|
+
throw new Error(
|
|
3142
|
+
`VALIDATION_FAILED: this approval node declares no decisionOutputs \u2014 outputs are not accepted. Declare the keys on the node config (decisionOutputs: [${outputKeys.map((k) => `'${k}'`).join(", ")}]) to let approvers hand them to the flow.`
|
|
3143
|
+
);
|
|
3144
|
+
}
|
|
3145
|
+
const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
|
|
3146
|
+
if (reserved.length) {
|
|
3147
|
+
throw new Error(
|
|
3148
|
+
`VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
|
|
3149
|
+
);
|
|
3150
|
+
}
|
|
3151
|
+
const undeclared = outputKeys.filter((k) => !declared.includes(k));
|
|
3152
|
+
if (undeclared.length) {
|
|
3153
|
+
throw new Error(
|
|
3154
|
+
`VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
|
|
3155
|
+
);
|
|
3156
|
+
}
|
|
3157
|
+
acceptedOutputs = { ...input.outputs };
|
|
3158
|
+
}
|
|
2093
3159
|
await this.engine.insert("sys_approval_action", {
|
|
2094
3160
|
id: uid("aact"),
|
|
2095
3161
|
request_id: requestId,
|
|
@@ -2097,23 +3163,23 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2097
3163
|
step_name: nodeId,
|
|
2098
3164
|
step_index: 0,
|
|
2099
3165
|
action: input.decision,
|
|
2100
|
-
actor_id:
|
|
3166
|
+
actor_id: actorId,
|
|
2101
3167
|
comment: input.comment ?? null,
|
|
2102
3168
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2103
3169
|
created_at: now
|
|
2104
|
-
}, { context:
|
|
3170
|
+
}, { context: SYSTEM_CTX2 });
|
|
2105
3171
|
const behavior = config.behavior ?? "first_response";
|
|
2106
|
-
if (input.decision === "approve" && behavior !== "first_response") {
|
|
3172
|
+
if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
|
|
2107
3173
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2108
3174
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
2109
3175
|
limit: 1e3,
|
|
2110
|
-
context:
|
|
3176
|
+
context: SYSTEM_CTX2
|
|
2111
3177
|
});
|
|
2112
3178
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
2113
3179
|
const snapshotGroups = config.__approverGroups;
|
|
2114
3180
|
let original;
|
|
2115
3181
|
let groupMap;
|
|
2116
|
-
if (snapshotGroups
|
|
3182
|
+
if (snapshotGroups) {
|
|
2117
3183
|
groupMap = snapshotGroups;
|
|
2118
3184
|
original = Object.keys(snapshotGroups);
|
|
2119
3185
|
} else {
|
|
@@ -2129,27 +3195,65 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2129
3195
|
await this.engine.update("sys_approval_request", {
|
|
2130
3196
|
id: requestId,
|
|
2131
3197
|
pending_approvers: stillPending.join(","),
|
|
2132
|
-
updated_at: now
|
|
2133
|
-
|
|
3198
|
+
updated_at: now,
|
|
3199
|
+
// #3447 P2: a mid-tally approval may carry outputs too (unanimous /
|
|
3200
|
+
// per_group co-sign, each approver contributing their declared keys)
|
|
3201
|
+
// — accumulate them on the snapshot so the FINALIZING decision hands
|
|
3202
|
+
// the merged set to the flow.
|
|
3203
|
+
...acceptedOutputs ? {
|
|
3204
|
+
node_config_json: JSON.stringify({
|
|
3205
|
+
...config,
|
|
3206
|
+
__decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
|
|
3207
|
+
})
|
|
3208
|
+
} : {}
|
|
3209
|
+
}, { context: SYSTEM_CTX2 });
|
|
2134
3210
|
await this.syncApproverIndex(requestId, stillPending, org, now);
|
|
2135
|
-
const fresh2 = await this.
|
|
3211
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2136
3212
|
return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
|
|
2137
3213
|
}
|
|
2138
3214
|
}
|
|
2139
3215
|
const finalStatus = input.decision === "approve" ? "approved" : "rejected";
|
|
3216
|
+
const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
|
|
2140
3217
|
await this.engine.update("sys_approval_request", {
|
|
2141
3218
|
id: requestId,
|
|
2142
3219
|
status: finalStatus,
|
|
2143
3220
|
pending_approvers: null,
|
|
2144
3221
|
completed_at: now,
|
|
2145
|
-
updated_at: now
|
|
2146
|
-
|
|
3222
|
+
updated_at: now,
|
|
3223
|
+
...mergedOutputs ? {
|
|
3224
|
+
node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
|
|
3225
|
+
} : {}
|
|
3226
|
+
}, { context: SYSTEM_CTX2 });
|
|
2147
3227
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2148
3228
|
if (config.approvalStatusField) {
|
|
2149
|
-
await this.mirrorStatusField(
|
|
3229
|
+
await this.mirrorStatusField(
|
|
3230
|
+
raw.object_name,
|
|
3231
|
+
raw.record_id,
|
|
3232
|
+
config.approvalStatusField,
|
|
3233
|
+
finalStatus,
|
|
3234
|
+
actingUserId(context)
|
|
3235
|
+
);
|
|
2150
3236
|
}
|
|
2151
|
-
const fresh = await this.
|
|
2152
|
-
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
|
|
3237
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
3238
|
+
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
|
|
3239
|
+
}
|
|
3240
|
+
/**
|
|
3241
|
+
* Continue the owning flow run after an outcome this service has already
|
|
3242
|
+
* authorized and written down (#3801).
|
|
3243
|
+
*
|
|
3244
|
+
* The `approval` node declares `resumeAuthority: 'service'`, so the engine
|
|
3245
|
+
* refuses any resume of an approval suspension that does not carry
|
|
3246
|
+
* {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
|
|
3247
|
+
* here so the marker is stamped in ONE place — a new outcome path cannot
|
|
3248
|
+
* quietly ship a resume that the gate then rejects at runtime, and nothing
|
|
3249
|
+
* in this file hands the marker to a caller-supplied signal.
|
|
3250
|
+
*
|
|
3251
|
+
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3252
|
+
* (approvals runs fine with no automation attached) and keep their own
|
|
3253
|
+
* try/catch, because what a failed resume means differs per path.
|
|
3254
|
+
*/
|
|
3255
|
+
async serviceResume(runId, signal) {
|
|
3256
|
+
await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
|
|
2153
3257
|
}
|
|
2154
3258
|
/**
|
|
2155
3259
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
@@ -2162,9 +3266,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2162
3266
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
2163
3267
|
const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
|
|
2164
3268
|
try {
|
|
2165
|
-
await this.
|
|
3269
|
+
await this.serviceResume(result.runId, {
|
|
2166
3270
|
branchLabel,
|
|
2167
|
-
|
|
3271
|
+
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3272
|
+
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
3273
|
+
// `expression` approver reads them as `vars.<nodeId>.<key>`.
|
|
3274
|
+
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3275
|
+
// whitelist already rejects them; this is defense in depth).
|
|
3276
|
+
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
2168
3277
|
});
|
|
2169
3278
|
resumed = true;
|
|
2170
3279
|
} catch (err) {
|
|
@@ -2198,11 +3307,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2198
3307
|
*/
|
|
2199
3308
|
async recall(requestId, input, context) {
|
|
2200
3309
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2201
|
-
|
|
3310
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2202
3311
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2203
3312
|
where: { id: requestId },
|
|
2204
3313
|
limit: 1,
|
|
2205
|
-
context:
|
|
3314
|
+
context: SYSTEM_CTX2
|
|
2206
3315
|
});
|
|
2207
3316
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2208
3317
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
@@ -2210,7 +3319,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2210
3319
|
if (raw.status !== "pending" && !inReviseWindow) {
|
|
2211
3320
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2212
3321
|
}
|
|
2213
|
-
if (!context.
|
|
3322
|
+
if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2214
3323
|
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
2215
3324
|
}
|
|
2216
3325
|
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
@@ -2226,20 +3335,26 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2226
3335
|
step_name: nodeId,
|
|
2227
3336
|
step_index: 0,
|
|
2228
3337
|
action: "recall",
|
|
2229
|
-
actor_id:
|
|
3338
|
+
actor_id: actorId,
|
|
2230
3339
|
comment: input.comment ?? null,
|
|
2231
3340
|
created_at: now
|
|
2232
|
-
}, { context:
|
|
3341
|
+
}, { context: SYSTEM_CTX2 });
|
|
2233
3342
|
await this.engine.update("sys_approval_request", {
|
|
2234
3343
|
id: requestId,
|
|
2235
3344
|
status: "recalled",
|
|
2236
3345
|
pending_approvers: null,
|
|
2237
3346
|
completed_at: now,
|
|
2238
3347
|
updated_at: now
|
|
2239
|
-
}, { context:
|
|
3348
|
+
}, { context: SYSTEM_CTX2 });
|
|
2240
3349
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2241
3350
|
if (config.approvalStatusField) {
|
|
2242
|
-
await this.mirrorStatusField(
|
|
3351
|
+
await this.mirrorStatusField(
|
|
3352
|
+
raw.object_name,
|
|
3353
|
+
raw.record_id,
|
|
3354
|
+
config.approvalStatusField,
|
|
3355
|
+
"recalled",
|
|
3356
|
+
actingUserId(context)
|
|
3357
|
+
);
|
|
2243
3358
|
}
|
|
2244
3359
|
let resumed = false;
|
|
2245
3360
|
if (inReviseWindow) {
|
|
@@ -2256,7 +3371,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2256
3371
|
}
|
|
2257
3372
|
} else if (runId && typeof this.automation?.resume === "function") {
|
|
2258
3373
|
try {
|
|
2259
|
-
await this.
|
|
3374
|
+
await this.serviceResume(runId, {
|
|
2260
3375
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
2261
3376
|
output: { decision: "recall", requestId }
|
|
2262
3377
|
});
|
|
@@ -2269,7 +3384,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2269
3384
|
});
|
|
2270
3385
|
}
|
|
2271
3386
|
}
|
|
2272
|
-
const fresh = await this.
|
|
3387
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2273
3388
|
return { request: fresh, runId, resumed };
|
|
2274
3389
|
}
|
|
2275
3390
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
@@ -2287,11 +3402,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2287
3402
|
* `output.autoRejected = true`) so instances cannot orbit forever.
|
|
2288
3403
|
*/
|
|
2289
3404
|
async sendBack(requestId, input, context) {
|
|
2290
|
-
|
|
3405
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2291
3406
|
const raw = await this.loadPendingRow(requestId);
|
|
2292
3407
|
const pending = csvSplit(raw.pending_approvers);
|
|
2293
|
-
if (!context.isSystem && !pending.includes(
|
|
2294
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3408
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
3409
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2295
3410
|
}
|
|
2296
3411
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2297
3412
|
const org = raw.organization_id ?? null;
|
|
@@ -2305,7 +3420,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2305
3420
|
const siblings = await this.engine.find("sys_approval_request", {
|
|
2306
3421
|
where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
|
|
2307
3422
|
limit: 500,
|
|
2308
|
-
context:
|
|
3423
|
+
context: SYSTEM_CTX2
|
|
2309
3424
|
});
|
|
2310
3425
|
priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
|
|
2311
3426
|
}
|
|
@@ -2316,10 +3431,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2316
3431
|
step_name: nodeId,
|
|
2317
3432
|
step_index: 0,
|
|
2318
3433
|
action: "revise",
|
|
2319
|
-
actor_id:
|
|
3434
|
+
actor_id: actorId,
|
|
2320
3435
|
comment: input.comment ?? null,
|
|
2321
3436
|
created_at: now
|
|
2322
|
-
}, { context:
|
|
3437
|
+
}, { context: SYSTEM_CTX2 });
|
|
2323
3438
|
if (priorSendBacks >= maxRevisions) {
|
|
2324
3439
|
await this.engine.insert("sys_approval_action", {
|
|
2325
3440
|
id: uid("aact"),
|
|
@@ -2328,25 +3443,31 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2328
3443
|
step_name: nodeId,
|
|
2329
3444
|
step_index: 0,
|
|
2330
3445
|
action: "reject",
|
|
2331
|
-
actor_id:
|
|
3446
|
+
actor_id: actorId,
|
|
2332
3447
|
comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
|
|
2333
3448
|
created_at: now
|
|
2334
|
-
}, { context:
|
|
3449
|
+
}, { context: SYSTEM_CTX2 });
|
|
2335
3450
|
await this.engine.update("sys_approval_request", {
|
|
2336
3451
|
id: requestId,
|
|
2337
3452
|
status: "rejected",
|
|
2338
3453
|
pending_approvers: null,
|
|
2339
3454
|
completed_at: now,
|
|
2340
3455
|
updated_at: now
|
|
2341
|
-
}, { context:
|
|
3456
|
+
}, { context: SYSTEM_CTX2 });
|
|
2342
3457
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2343
3458
|
if (config.approvalStatusField) {
|
|
2344
|
-
await this.mirrorStatusField(
|
|
3459
|
+
await this.mirrorStatusField(
|
|
3460
|
+
raw.object_name,
|
|
3461
|
+
raw.record_id,
|
|
3462
|
+
config.approvalStatusField,
|
|
3463
|
+
"rejected",
|
|
3464
|
+
actingUserId(context)
|
|
3465
|
+
);
|
|
2345
3466
|
}
|
|
2346
3467
|
let resumed2 = false;
|
|
2347
3468
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2348
3469
|
try {
|
|
2349
|
-
await this.
|
|
3470
|
+
await this.serviceResume(runId, {
|
|
2350
3471
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
2351
3472
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
2352
3473
|
});
|
|
@@ -2363,7 +3484,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2363
3484
|
await this.notify({
|
|
2364
3485
|
topic: "approval.returned",
|
|
2365
3486
|
audience: [String(raw.submitter_id)],
|
|
2366
|
-
actorId
|
|
3487
|
+
actorId,
|
|
2367
3488
|
source: { object: "sys_approval_request", id: requestId },
|
|
2368
3489
|
payload: {
|
|
2369
3490
|
title: "Approval auto-rejected",
|
|
@@ -2372,7 +3493,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2372
3493
|
}
|
|
2373
3494
|
});
|
|
2374
3495
|
}
|
|
2375
|
-
const fresh2 = await this.
|
|
3496
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2376
3497
|
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
2377
3498
|
}
|
|
2378
3499
|
await this.engine.update("sys_approval_request", {
|
|
@@ -2381,15 +3502,21 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2381
3502
|
pending_approvers: null,
|
|
2382
3503
|
completed_at: now,
|
|
2383
3504
|
updated_at: now
|
|
2384
|
-
}, { context:
|
|
3505
|
+
}, { context: SYSTEM_CTX2 });
|
|
2385
3506
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2386
3507
|
if (config.approvalStatusField) {
|
|
2387
|
-
await this.mirrorStatusField(
|
|
3508
|
+
await this.mirrorStatusField(
|
|
3509
|
+
raw.object_name,
|
|
3510
|
+
raw.record_id,
|
|
3511
|
+
config.approvalStatusField,
|
|
3512
|
+
"returned",
|
|
3513
|
+
actingUserId(context)
|
|
3514
|
+
);
|
|
2388
3515
|
}
|
|
2389
3516
|
let resumed = false;
|
|
2390
3517
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2391
3518
|
try {
|
|
2392
|
-
await this.
|
|
3519
|
+
await this.serviceResume(runId, {
|
|
2393
3520
|
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
2394
3521
|
output: { decision: "revise", requestId }
|
|
2395
3522
|
});
|
|
@@ -2406,7 +3533,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2406
3533
|
await this.notify({
|
|
2407
3534
|
topic: "approval.returned",
|
|
2408
3535
|
audience: [String(raw.submitter_id)],
|
|
2409
|
-
actorId
|
|
3536
|
+
actorId,
|
|
2410
3537
|
source: { object: "sys_approval_request", id: requestId },
|
|
2411
3538
|
payload: {
|
|
2412
3539
|
title: "Sent back for revision",
|
|
@@ -2415,7 +3542,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2415
3542
|
}
|
|
2416
3543
|
});
|
|
2417
3544
|
}
|
|
2418
|
-
const fresh = await this.
|
|
3545
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2419
3546
|
return { request: fresh, runId, resumed };
|
|
2420
3547
|
}
|
|
2421
3548
|
/**
|
|
@@ -2426,25 +3553,25 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2426
3553
|
* round-N+1 request — fresh approver slate, record re-locks.
|
|
2427
3554
|
*/
|
|
2428
3555
|
async resubmit(requestId, input, context) {
|
|
2429
|
-
|
|
3556
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2430
3557
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2431
3558
|
where: { id: requestId },
|
|
2432
3559
|
limit: 1,
|
|
2433
|
-
context:
|
|
3560
|
+
context: SYSTEM_CTX2
|
|
2434
3561
|
});
|
|
2435
3562
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2436
3563
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2437
3564
|
if (raw.status !== "returned") {
|
|
2438
3565
|
throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
|
|
2439
3566
|
}
|
|
2440
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3567
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2441
3568
|
throw new Error("FORBIDDEN: only the submitter may resubmit");
|
|
2442
3569
|
}
|
|
2443
3570
|
await this.assertLatestForRun(raw);
|
|
2444
3571
|
const colliding = await this.engine.find("sys_approval_request", {
|
|
2445
3572
|
where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
|
|
2446
3573
|
limit: 1,
|
|
2447
|
-
context:
|
|
3574
|
+
context: SYSTEM_CTX2
|
|
2448
3575
|
});
|
|
2449
3576
|
if (Array.isArray(colliding) && colliding[0]) {
|
|
2450
3577
|
throw new Error(
|
|
@@ -2462,14 +3589,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2462
3589
|
step_name: nodeId,
|
|
2463
3590
|
step_index: 0,
|
|
2464
3591
|
action: "resubmit",
|
|
2465
|
-
actor_id:
|
|
3592
|
+
actor_id: actorId,
|
|
2466
3593
|
comment: input.comment ?? null,
|
|
2467
3594
|
created_at: now
|
|
2468
|
-
}, { context:
|
|
3595
|
+
}, { context: SYSTEM_CTX2 });
|
|
2469
3596
|
let resumed = false;
|
|
2470
3597
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2471
3598
|
try {
|
|
2472
|
-
await this.
|
|
3599
|
+
await this.serviceResume(runId, {
|
|
2473
3600
|
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
2474
3601
|
output: { resubmitted: true, requestId }
|
|
2475
3602
|
});
|
|
@@ -2482,7 +3609,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2482
3609
|
});
|
|
2483
3610
|
}
|
|
2484
3611
|
}
|
|
2485
|
-
const fresh = await this.
|
|
3612
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2486
3613
|
return { request: fresh, runId, resumed };
|
|
2487
3614
|
}
|
|
2488
3615
|
/**
|
|
@@ -2517,7 +3644,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2517
3644
|
where: { flow_run_id: runId },
|
|
2518
3645
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2519
3646
|
limit: 1,
|
|
2520
|
-
context:
|
|
3647
|
+
context: SYSTEM_CTX2
|
|
2521
3648
|
});
|
|
2522
3649
|
const latest = Array.isArray(rows) ? rows[0] : null;
|
|
2523
3650
|
if (latest && String(latest.id) !== String(raw.id)) {
|
|
@@ -2528,25 +3655,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2528
3655
|
/**
|
|
2529
3656
|
* Hand a pending-approver slot to someone else. `from` defaults to the
|
|
2530
3657
|
* actor itself; the actor must hold the slot being handed over (or be a
|
|
2531
|
-
* system caller).
|
|
3658
|
+
* system caller). A privileged admin (#3424) may reassign a request whose
|
|
3659
|
+
* slate holds no real user — an unstaffed-position literal — by handing the
|
|
3660
|
+
* whole request to a real approver, rescuing it from the locked dead-end.
|
|
3661
|
+
* Audits `reassign` and notifies the new approver.
|
|
2532
3662
|
*/
|
|
2533
3663
|
async reassign(requestId, input, context) {
|
|
2534
|
-
|
|
3664
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2535
3665
|
const to = String(input?.to ?? "").trim();
|
|
2536
3666
|
if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
|
|
2537
3667
|
const raw = await this.loadPendingRow(requestId);
|
|
2538
3668
|
const pending = csvSplit(raw.pending_approvers);
|
|
2539
|
-
const from = String(input.from ?? input.actorId).trim();
|
|
2540
|
-
if (!pending.includes(from)) {
|
|
2541
|
-
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
2542
|
-
}
|
|
2543
|
-
if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
|
|
2544
|
-
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
2545
|
-
}
|
|
2546
3669
|
if (pending.includes(to)) {
|
|
2547
3670
|
throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
|
|
2548
3671
|
}
|
|
2549
|
-
const
|
|
3672
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3673
|
+
const from = String(input.from ?? actorId).trim();
|
|
3674
|
+
let next;
|
|
3675
|
+
if (pending.includes(from)) {
|
|
3676
|
+
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
3677
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3678
|
+
}
|
|
3679
|
+
next = pending.map((a) => a === from ? to : a);
|
|
3680
|
+
} else if (isOverride) {
|
|
3681
|
+
next = [to];
|
|
3682
|
+
} else {
|
|
3683
|
+
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
3684
|
+
}
|
|
2550
3685
|
const now = this.clock.now().toISOString();
|
|
2551
3686
|
await this.engine.insert("sys_approval_action", {
|
|
2552
3687
|
id: uid("aact"),
|
|
@@ -2555,10 +3690,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2555
3690
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2556
3691
|
step_index: 0,
|
|
2557
3692
|
action: "reassign",
|
|
2558
|
-
actor_id:
|
|
3693
|
+
actor_id: actorId,
|
|
2559
3694
|
comment: input.comment ?? `${from} \u2192 ${to}`,
|
|
2560
3695
|
created_at: now
|
|
2561
|
-
}, { context:
|
|
3696
|
+
}, { context: SYSTEM_CTX2 });
|
|
2562
3697
|
let configPatch = {};
|
|
2563
3698
|
try {
|
|
2564
3699
|
const cfg = parseJson(raw.node_config_json, null);
|
|
@@ -2575,12 +3710,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2575
3710
|
pending_approvers: next.join(","),
|
|
2576
3711
|
updated_at: now,
|
|
2577
3712
|
...configPatch
|
|
2578
|
-
}, { context:
|
|
3713
|
+
}, { context: SYSTEM_CTX2 });
|
|
2579
3714
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
2580
3715
|
await this.notify({
|
|
2581
3716
|
topic: "approval.reassigned",
|
|
2582
3717
|
audience: [to],
|
|
2583
|
-
actorId
|
|
3718
|
+
actorId,
|
|
2584
3719
|
source: { object: "sys_approval_request", id: requestId },
|
|
2585
3720
|
dedupKey: `approval-reassign-${requestId}-${to}`,
|
|
2586
3721
|
payload: {
|
|
@@ -2589,7 +3724,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2589
3724
|
actionUrl: "/system/approvals"
|
|
2590
3725
|
}
|
|
2591
3726
|
});
|
|
2592
|
-
const fresh = await this.
|
|
3727
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2593
3728
|
return { request: fresh };
|
|
2594
3729
|
}
|
|
2595
3730
|
/**
|
|
@@ -2597,16 +3732,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2597
3732
|
* reminder per {@link REMIND_COOLDOWN_MS} per request.
|
|
2598
3733
|
*/
|
|
2599
3734
|
async remind(requestId, input, context) {
|
|
2600
|
-
|
|
3735
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2601
3736
|
const raw = await this.loadPendingRow(requestId);
|
|
2602
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3737
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2603
3738
|
throw new Error("FORBIDDEN: only the submitter may send reminders");
|
|
2604
3739
|
}
|
|
2605
3740
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2606
3741
|
where: { request_id: requestId, action: "remind" },
|
|
2607
3742
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2608
3743
|
limit: 1,
|
|
2609
|
-
context:
|
|
3744
|
+
context: SYSTEM_CTX2
|
|
2610
3745
|
});
|
|
2611
3746
|
const last = Array.isArray(acts) ? acts[0] : null;
|
|
2612
3747
|
const now = this.clock.now();
|
|
@@ -2622,10 +3757,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2622
3757
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2623
3758
|
step_index: 0,
|
|
2624
3759
|
action: "remind",
|
|
2625
|
-
actor_id:
|
|
3760
|
+
actor_id: actorId,
|
|
2626
3761
|
comment: input.comment ?? null,
|
|
2627
3762
|
created_at: nowIso
|
|
2628
|
-
}, { context:
|
|
3763
|
+
}, { context: SYSTEM_CTX2 });
|
|
2629
3764
|
let notified = 0;
|
|
2630
3765
|
const concrete = pending.filter((a) => a && !a.includes(":"));
|
|
2631
3766
|
const literals = pending.filter((a) => a && a.includes(":"));
|
|
@@ -2635,7 +3770,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2635
3770
|
notified += await this.notify({
|
|
2636
3771
|
topic: "approval.reminder",
|
|
2637
3772
|
audience: [approver],
|
|
2638
|
-
actorId
|
|
3773
|
+
actorId,
|
|
2639
3774
|
source: { object: "sys_approval_request", id: requestId },
|
|
2640
3775
|
dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
|
|
2641
3776
|
payload: {
|
|
@@ -2660,7 +3795,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2660
3795
|
notified += await this.notify({
|
|
2661
3796
|
topic: "approval.reminder",
|
|
2662
3797
|
audience: literals,
|
|
2663
|
-
actorId
|
|
3798
|
+
actorId,
|
|
2664
3799
|
source: { object: "sys_approval_request", id: requestId },
|
|
2665
3800
|
dedupKey: `approval-remind-${requestId}-${nowIso}`,
|
|
2666
3801
|
payload: {
|
|
@@ -2670,7 +3805,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2670
3805
|
}
|
|
2671
3806
|
});
|
|
2672
3807
|
}
|
|
2673
|
-
const fresh = await this.
|
|
3808
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2674
3809
|
return { request: fresh, notified };
|
|
2675
3810
|
}
|
|
2676
3811
|
// ── Actionable links (ADR-0043) ──────────────────────────────
|
|
@@ -2705,7 +3840,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2705
3840
|
expires_at: expires,
|
|
2706
3841
|
consumed_at: null,
|
|
2707
3842
|
created_at: now.toISOString()
|
|
2708
|
-
}, { context:
|
|
3843
|
+
}, { context: SYSTEM_CTX2 });
|
|
2709
3844
|
out[action] = rawToken;
|
|
2710
3845
|
}
|
|
2711
3846
|
return out;
|
|
@@ -2718,7 +3853,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2718
3853
|
const rows = await this.engine.find("sys_approval_token", {
|
|
2719
3854
|
where: { token_hash: hash },
|
|
2720
3855
|
limit: 1,
|
|
2721
|
-
context:
|
|
3856
|
+
context: SYSTEM_CTX2
|
|
2722
3857
|
});
|
|
2723
3858
|
const token = Array.isArray(rows) ? rows[0] : null;
|
|
2724
3859
|
if (!token) return { ok: false, reason: "invalid" };
|
|
@@ -2726,7 +3861,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2726
3861
|
if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
|
|
2727
3862
|
return { ok: false, reason: "expired" };
|
|
2728
3863
|
}
|
|
2729
|
-
const request = await this.getRequest(token.request_id,
|
|
3864
|
+
const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
|
|
2730
3865
|
if (!request || request.status !== "pending") {
|
|
2731
3866
|
return { ok: false, reason: "not_pending", request: request ?? void 0 };
|
|
2732
3867
|
}
|
|
@@ -2751,12 +3886,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2751
3886
|
await this.engine.update("sys_approval_token", {
|
|
2752
3887
|
id: res.token.id,
|
|
2753
3888
|
consumed_at: this.clock.now().toISOString()
|
|
2754
|
-
}, { context:
|
|
3889
|
+
}, { context: SYSTEM_CTX2 });
|
|
2755
3890
|
const out = await this.decide(res.token.request_id, {
|
|
2756
3891
|
decision: res.token.action,
|
|
2757
3892
|
actorId: res.token.approver_id,
|
|
2758
3893
|
comment: "Via action link"
|
|
2759
|
-
|
|
3894
|
+
// The token IS the authentication (#3783): it is single-use, hashed at
|
|
3895
|
+
// rest and bound to one approver, who `resolveActionToken` has just
|
|
3896
|
+
// re-checked still holds a pending slot. So this decision has a real
|
|
3897
|
+
// acting user even though no session carried it — name them on the
|
|
3898
|
+
// context, so the status mirror and every flow it cascades into are
|
|
3899
|
+
// attributed exactly like a decision made through the UI. Elevation is
|
|
3900
|
+
// unchanged: `isSystem` still stands in for the missing session.
|
|
3901
|
+
}, { ...SYSTEM_CTX2, userId: res.token.approver_id });
|
|
2760
3902
|
return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
|
|
2761
3903
|
}
|
|
2762
3904
|
/**
|
|
@@ -2764,12 +3906,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2764
3906
|
* pending — a thread interaction, not a flow decision.
|
|
2765
3907
|
*/
|
|
2766
3908
|
async requestInfo(requestId, input, context) {
|
|
2767
|
-
|
|
3909
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2768
3910
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2769
3911
|
const raw = await this.loadPendingRow(requestId);
|
|
2770
3912
|
const pending = csvSplit(raw.pending_approvers);
|
|
2771
|
-
if (!context.isSystem && !pending.includes(
|
|
2772
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3913
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
3914
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2773
3915
|
}
|
|
2774
3916
|
const now = this.clock.now().toISOString();
|
|
2775
3917
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2779,15 +3921,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2779
3921
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2780
3922
|
step_index: 0,
|
|
2781
3923
|
action: "request_info",
|
|
2782
|
-
actor_id:
|
|
3924
|
+
actor_id: actorId,
|
|
2783
3925
|
comment: input.comment.trim(),
|
|
2784
3926
|
created_at: now
|
|
2785
|
-
}, { context:
|
|
3927
|
+
}, { context: SYSTEM_CTX2 });
|
|
2786
3928
|
if (raw.submitter_id) {
|
|
2787
3929
|
await this.notify({
|
|
2788
3930
|
topic: "approval.request_info",
|
|
2789
3931
|
audience: [String(raw.submitter_id)],
|
|
2790
|
-
actorId
|
|
3932
|
+
actorId,
|
|
2791
3933
|
source: { object: "sys_approval_request", id: requestId },
|
|
2792
3934
|
payload: {
|
|
2793
3935
|
title: "More information requested",
|
|
@@ -2796,18 +3938,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2796
3938
|
}
|
|
2797
3939
|
});
|
|
2798
3940
|
}
|
|
2799
|
-
const fresh = await this.
|
|
3941
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2800
3942
|
return { request: fresh };
|
|
2801
3943
|
}
|
|
2802
3944
|
/** Free-form reply on the thread (submitter or any pending approver). */
|
|
2803
3945
|
async comment(requestId, input, context) {
|
|
2804
|
-
|
|
3946
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2805
3947
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2806
3948
|
const raw = await this.loadPendingRow(requestId);
|
|
2807
3949
|
const pending = csvSplit(raw.pending_approvers);
|
|
2808
|
-
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(
|
|
2809
|
-
if (!context.isSystem && !isSubmitter && !pending.includes(
|
|
2810
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3950
|
+
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
|
|
3951
|
+
if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
|
|
3952
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
|
|
2811
3953
|
}
|
|
2812
3954
|
const now = this.clock.now().toISOString();
|
|
2813
3955
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2817,16 +3959,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2817
3959
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2818
3960
|
step_index: 0,
|
|
2819
3961
|
action: "comment",
|
|
2820
|
-
actor_id:
|
|
3962
|
+
actor_id: actorId,
|
|
2821
3963
|
comment: input.comment.trim(),
|
|
2822
3964
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2823
3965
|
created_at: now
|
|
2824
|
-
}, { context:
|
|
3966
|
+
}, { context: SYSTEM_CTX2 });
|
|
2825
3967
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
2826
3968
|
await this.notify({
|
|
2827
3969
|
topic: "approval.comment",
|
|
2828
3970
|
audience,
|
|
2829
|
-
actorId
|
|
3971
|
+
actorId,
|
|
2830
3972
|
source: { object: "sys_approval_request", id: requestId },
|
|
2831
3973
|
payload: {
|
|
2832
3974
|
title: "New comment on an approval",
|
|
@@ -2834,7 +3976,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2834
3976
|
actionUrl: "/system/approvals"
|
|
2835
3977
|
}
|
|
2836
3978
|
});
|
|
2837
|
-
const fresh = await this.
|
|
3979
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2838
3980
|
return { request: fresh };
|
|
2839
3981
|
}
|
|
2840
3982
|
// ── SLA escalation (ADR-0042) ─────────────────────────────────
|
|
@@ -2851,7 +3993,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2851
3993
|
rows = await this.engine.find("sys_approval_request", {
|
|
2852
3994
|
where: { status: "pending" },
|
|
2853
3995
|
limit: 500,
|
|
2854
|
-
context:
|
|
3996
|
+
context: SYSTEM_CTX2
|
|
2855
3997
|
}) ?? [];
|
|
2856
3998
|
} catch (err) {
|
|
2857
3999
|
this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
|
|
@@ -2870,7 +4012,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2870
4012
|
const prior = await this.engine.find("sys_approval_action", {
|
|
2871
4013
|
where: { request_id: raw.id, action: "escalate" },
|
|
2872
4014
|
limit: 1,
|
|
2873
|
-
context:
|
|
4015
|
+
context: SYSTEM_CTX2
|
|
2874
4016
|
});
|
|
2875
4017
|
if (Array.isArray(prior) && prior[0]) continue;
|
|
2876
4018
|
await this.escalateRequest(raw, esc2);
|
|
@@ -2887,6 +4029,130 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2887
4029
|
}
|
|
2888
4030
|
return { scanned: rows.length, escalated };
|
|
2889
4031
|
}
|
|
4032
|
+
// ── Dead-run release (#3456) ──────────────────────────────────
|
|
4033
|
+
/**
|
|
4034
|
+
* One dead-run sweep: a pending request whose owning flow run has reached a
|
|
4035
|
+
* TERMINAL state can never be decided — nothing is left to resume — so the
|
|
4036
|
+
* request is finalised as `recalled` and, with `lockRecord`, the record it was
|
|
4037
|
+
* holding is released.
|
|
4038
|
+
*
|
|
4039
|
+
* This is the recovery half of #3456. The prevention half is the record lock's
|
|
4040
|
+
* owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
|
|
4041
|
+
* itself on its own lock in the first place; this sweep cleans up the runs that
|
|
4042
|
+
* still die — for any reason, including a process crash, which no in-band
|
|
4043
|
+
* handler can catch because the process that would have run it is gone.
|
|
4044
|
+
*
|
|
4045
|
+
* **Fail-safe by construction.** It acts only on an explicit terminal status
|
|
4046
|
+
* from a closed set. Every other answer — `paused` (the normal state of a run
|
|
4047
|
+
* waiting on its approval), `running`, an unrecognised status, `null` (unknown
|
|
4048
|
+
* run, evicted log, no durable store), a `getRun` that throws, or no automation
|
|
4049
|
+
* engine at all — is read as "still alive" and left strictly alone. The failure
|
|
4050
|
+
* mode is therefore "a dead run's lock survives until an admin recalls it"
|
|
4051
|
+
* (today's behaviour, #3424), never "a live approval is destroyed".
|
|
4052
|
+
*
|
|
4053
|
+
* `recalled` is the finalisation because it is the platform's existing terminal
|
|
4054
|
+
* state for *a live request that ended without a decision*; the audit row names
|
|
4055
|
+
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4056
|
+
* release is never mistaken for a submitter's withdrawal.
|
|
4057
|
+
*/
|
|
4058
|
+
async releaseDeadRunRequests() {
|
|
4059
|
+
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4060
|
+
let rows = [];
|
|
4061
|
+
try {
|
|
4062
|
+
rows = await this.engine.find("sys_approval_request", {
|
|
4063
|
+
where: { status: "pending" },
|
|
4064
|
+
limit: 500,
|
|
4065
|
+
context: SYSTEM_CTX2
|
|
4066
|
+
}) ?? [];
|
|
4067
|
+
} catch (err) {
|
|
4068
|
+
this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
|
|
4069
|
+
error: err?.message ?? String(err)
|
|
4070
|
+
});
|
|
4071
|
+
return { scanned: 0, released: 0 };
|
|
4072
|
+
}
|
|
4073
|
+
let released = 0;
|
|
4074
|
+
for (const raw of rows) {
|
|
4075
|
+
try {
|
|
4076
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4077
|
+
if (!runId) continue;
|
|
4078
|
+
let status;
|
|
4079
|
+
try {
|
|
4080
|
+
const run = await this.automation.getRun(runId);
|
|
4081
|
+
status = typeof run?.status === "string" ? run.status : void 0;
|
|
4082
|
+
} catch (err) {
|
|
4083
|
+
this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
|
|
4084
|
+
request: raw?.id,
|
|
4085
|
+
run: runId,
|
|
4086
|
+
error: err?.message ?? String(err)
|
|
4087
|
+
});
|
|
4088
|
+
continue;
|
|
4089
|
+
}
|
|
4090
|
+
if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
|
|
4091
|
+
await this.abandonForDeadRun(raw, runId, status);
|
|
4092
|
+
released++;
|
|
4093
|
+
} catch (err) {
|
|
4094
|
+
this.logger?.warn?.("[approvals] dead-run release failed for request", {
|
|
4095
|
+
request: raw?.id,
|
|
4096
|
+
error: err?.message ?? String(err)
|
|
4097
|
+
});
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
if (released > 0) {
|
|
4101
|
+
this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
|
|
4102
|
+
}
|
|
4103
|
+
return { scanned: rows.length, released };
|
|
4104
|
+
}
|
|
4105
|
+
/**
|
|
4106
|
+
* Finalise one pending request whose owning run is terminal. Mirrors the
|
|
4107
|
+
* shape of {@link recall} — audit row first (so a crash mid-release leaves a
|
|
4108
|
+
* trace of the intent), then the status transition, approver-index sync and
|
|
4109
|
+
* the optional status-field mirror. No resume/cancel of the run: it is already
|
|
4110
|
+
* terminal, which is precisely why we are here.
|
|
4111
|
+
*/
|
|
4112
|
+
async abandonForDeadRun(raw, runId, runStatus) {
|
|
4113
|
+
const org = raw.organization_id ?? null;
|
|
4114
|
+
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
4115
|
+
const now = this.clock.now().toISOString();
|
|
4116
|
+
await this.engine.insert("sys_approval_action", {
|
|
4117
|
+
id: uid("aact"),
|
|
4118
|
+
request_id: raw.id,
|
|
4119
|
+
organization_id: org,
|
|
4120
|
+
step_name: nodeId,
|
|
4121
|
+
step_index: 0,
|
|
4122
|
+
action: "recall",
|
|
4123
|
+
actor_id: DEAD_RUN_ACTOR_ID,
|
|
4124
|
+
comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
|
|
4125
|
+
created_at: now
|
|
4126
|
+
}, { context: SYSTEM_CTX2 });
|
|
4127
|
+
await this.engine.update("sys_approval_request", {
|
|
4128
|
+
id: raw.id,
|
|
4129
|
+
status: "recalled",
|
|
4130
|
+
pending_approvers: null,
|
|
4131
|
+
completed_at: now,
|
|
4132
|
+
updated_at: now
|
|
4133
|
+
}, { context: SYSTEM_CTX2 });
|
|
4134
|
+
await this.syncApproverIndex(raw.id, [], org, now);
|
|
4135
|
+
const config = parseJson(
|
|
4136
|
+
raw.node_config_json,
|
|
4137
|
+
{ approvers: [], behavior: "first_response" }
|
|
4138
|
+
);
|
|
4139
|
+
if (config.approvalStatusField) {
|
|
4140
|
+
await this.mirrorStatusField(
|
|
4141
|
+
raw.object_name,
|
|
4142
|
+
raw.record_id,
|
|
4143
|
+
config.approvalStatusField,
|
|
4144
|
+
"recalled",
|
|
4145
|
+
null
|
|
4146
|
+
);
|
|
4147
|
+
}
|
|
4148
|
+
this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
|
|
4149
|
+
request: raw.id,
|
|
4150
|
+
run: runId,
|
|
4151
|
+
runStatus,
|
|
4152
|
+
object: raw.object_name,
|
|
4153
|
+
record: raw.record_id
|
|
4154
|
+
});
|
|
4155
|
+
}
|
|
2890
4156
|
/** Execute the configured escalation action for one overdue request. */
|
|
2891
4157
|
async escalateRequest(raw, esc2) {
|
|
2892
4158
|
const action = esc2.action ?? "notify";
|
|
@@ -2912,13 +4178,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2912
4178
|
actor_id: SLA_ACTOR_ID,
|
|
2913
4179
|
comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
|
|
2914
4180
|
created_at: now
|
|
2915
|
-
}, { context:
|
|
4181
|
+
}, { context: SYSTEM_CTX2 });
|
|
2916
4182
|
if (action === "reassign" && escalatees.length) {
|
|
2917
4183
|
await this.engine.update("sys_approval_request", {
|
|
2918
4184
|
id: raw.id,
|
|
2919
4185
|
pending_approvers: escalatees.join(","),
|
|
2920
4186
|
updated_at: now
|
|
2921
|
-
}, { context:
|
|
4187
|
+
}, { context: SYSTEM_CTX2 });
|
|
2922
4188
|
await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
|
|
2923
4189
|
await this.notify({
|
|
2924
4190
|
topic: "approval.escalated",
|
|
@@ -2936,7 +4202,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2936
4202
|
decision: action === "auto_approve" ? "approve" : "reject",
|
|
2937
4203
|
actorId: SLA_ACTOR_ID,
|
|
2938
4204
|
comment: "SLA escalation"
|
|
2939
|
-
},
|
|
4205
|
+
}, SYSTEM_CTX2);
|
|
2940
4206
|
} else {
|
|
2941
4207
|
await this.notify({
|
|
2942
4208
|
topic: "approval.sla_breached",
|
|
@@ -3004,7 +4270,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3004
4270
|
where: { id: { $in: targets } },
|
|
3005
4271
|
fields: ["id", "name", "email"],
|
|
3006
4272
|
limit: targets.length,
|
|
3007
|
-
context:
|
|
4273
|
+
context: SYSTEM_CTX2
|
|
3008
4274
|
});
|
|
3009
4275
|
for (const u of users ?? []) {
|
|
3010
4276
|
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
@@ -3018,7 +4284,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3018
4284
|
where: { email: { $in: unresolvedEmails } },
|
|
3019
4285
|
fields: ["email", "name"],
|
|
3020
4286
|
limit: unresolvedEmails.length,
|
|
3021
|
-
context:
|
|
4287
|
+
context: SYSTEM_CTX2
|
|
3022
4288
|
});
|
|
3023
4289
|
for (const u of users ?? []) {
|
|
3024
4290
|
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
@@ -3044,11 +4310,32 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3044
4310
|
return [];
|
|
3045
4311
|
}
|
|
3046
4312
|
}
|
|
4313
|
+
/**
|
|
4314
|
+
* Field key → display label for an object's schema. Lets the inbox summary
|
|
4315
|
+
* show a human field name ("考核状态") instead of a title-cased machine key
|
|
4316
|
+
* ("Assessment Status"). For a single-locale project the schema label already
|
|
4317
|
+
* IS the localized string; symmetric with `resolveDisplayField`/lookup
|
|
4318
|
+
* resolution that power `payload_display`.
|
|
4319
|
+
*/
|
|
4320
|
+
resolveFieldLabels(object) {
|
|
4321
|
+
try {
|
|
4322
|
+
const schema = this.engine.getSchema?.(object);
|
|
4323
|
+
const fields = schema?.fields ?? {};
|
|
4324
|
+
const out = {};
|
|
4325
|
+
for (const [key, f] of Object.entries(fields)) {
|
|
4326
|
+
if (f?.label) out[key] = String(f.label);
|
|
4327
|
+
}
|
|
4328
|
+
return out;
|
|
4329
|
+
} catch {
|
|
4330
|
+
return {};
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
3047
4333
|
/**
|
|
3048
4334
|
* Attach inbox display fields to rows so clients never render a raw
|
|
3049
4335
|
* identifier: `record_title`, `submitter_name`, `object_label`,
|
|
3050
|
-
* `pending_approver_names` (user-id approvers),
|
|
3051
|
-
* (lookup foreign keys in the snapshot → referenced record titles)
|
|
4336
|
+
* `pending_approver_names` (user-id approvers), `payload_display`
|
|
4337
|
+
* (lookup foreign keys in the snapshot → referenced record titles), and
|
|
4338
|
+
* `payload_labels` (snapshot field keys → the target object's field labels).
|
|
3052
4339
|
* Batched: one query per distinct object (target + referenced) plus one
|
|
3053
4340
|
* `sys_user` lookup. Best-effort — a deleted record falls back to the
|
|
3054
4341
|
* payload snapshot, and any failure leaves the field unset rather than
|
|
@@ -3080,7 +4367,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3080
4367
|
const recs = await this.engine.find(object, {
|
|
3081
4368
|
where: { id: { $in: ids } },
|
|
3082
4369
|
limit: ids.length,
|
|
3083
|
-
context:
|
|
4370
|
+
context: SYSTEM_CTX2
|
|
3084
4371
|
});
|
|
3085
4372
|
for (const rec of recs ?? []) {
|
|
3086
4373
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3090,9 +4377,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3090
4377
|
}
|
|
3091
4378
|
}
|
|
3092
4379
|
const lookupFieldsByObject = /* @__PURE__ */ new Map();
|
|
4380
|
+
const fieldLabelsByObject = /* @__PURE__ */ new Map();
|
|
3093
4381
|
for (const object of byObject.keys()) {
|
|
3094
4382
|
const lookups = this.resolveLookupFields(object);
|
|
3095
4383
|
if (lookups.length) lookupFieldsByObject.set(object, lookups);
|
|
4384
|
+
const labels = this.resolveFieldLabels(object);
|
|
4385
|
+
if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
|
|
3096
4386
|
}
|
|
3097
4387
|
const refIds = /* @__PURE__ */ new Map();
|
|
3098
4388
|
for (const r of rows) {
|
|
@@ -3118,7 +4408,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3118
4408
|
const recs = await this.engine.find(object, {
|
|
3119
4409
|
where: { id: { $in: ids } },
|
|
3120
4410
|
limit: ids.length,
|
|
3121
|
-
context:
|
|
4411
|
+
context: SYSTEM_CTX2
|
|
3122
4412
|
});
|
|
3123
4413
|
for (const rec of recs ?? []) {
|
|
3124
4414
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3159,6 +4449,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3159
4449
|
}
|
|
3160
4450
|
if (Object.keys(display).length) r.payload_display = display;
|
|
3161
4451
|
}
|
|
4452
|
+
const fieldLabels = fieldLabelsByObject.get(r.object_name);
|
|
4453
|
+
if (fieldLabels && r.payload && typeof r.payload === "object") {
|
|
4454
|
+
const labels = {};
|
|
4455
|
+
for (const key of Object.keys(r.payload)) {
|
|
4456
|
+
const l = fieldLabels[key];
|
|
4457
|
+
if (l) labels[key] = l;
|
|
4458
|
+
}
|
|
4459
|
+
if (Object.keys(labels).length) r.payload_labels = labels;
|
|
4460
|
+
}
|
|
3162
4461
|
}
|
|
3163
4462
|
}
|
|
3164
4463
|
// ── Pending-approver index (issue #1745) ─────────────────────
|
|
@@ -3174,12 +4473,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3174
4473
|
const existing = await this.engine.find("sys_approval_approver", {
|
|
3175
4474
|
where: { request_id: requestId },
|
|
3176
4475
|
limit: 500,
|
|
3177
|
-
context:
|
|
4476
|
+
context: SYSTEM_CTX2
|
|
3178
4477
|
});
|
|
3179
4478
|
const rows = Array.isArray(existing) ? existing : [];
|
|
3180
4479
|
for (const row of rows) {
|
|
3181
4480
|
if (desired.has(String(row.approver))) desired.delete(String(row.approver));
|
|
3182
|
-
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4481
|
+
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3183
4482
|
}
|
|
3184
4483
|
for (const approver of desired) {
|
|
3185
4484
|
await this.engine.insert("sys_approval_approver", {
|
|
@@ -3188,7 +4487,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3188
4487
|
approver,
|
|
3189
4488
|
organization_id: org,
|
|
3190
4489
|
created_at: now
|
|
3191
|
-
}, { context:
|
|
4490
|
+
}, { context: SYSTEM_CTX2 });
|
|
3192
4491
|
}
|
|
3193
4492
|
}
|
|
3194
4493
|
/**
|
|
@@ -3206,7 +4505,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3206
4505
|
fields: ["id", "pending_approvers", "organization_id"],
|
|
3207
4506
|
limit: PAGE,
|
|
3208
4507
|
offset,
|
|
3209
|
-
context:
|
|
4508
|
+
context: SYSTEM_CTX2
|
|
3210
4509
|
});
|
|
3211
4510
|
const rows = Array.isArray(batch) ? batch : [];
|
|
3212
4511
|
for (const r of rows) {
|
|
@@ -3223,7 +4522,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3223
4522
|
orderBy: [{ field: "created_at", order: "asc" }],
|
|
3224
4523
|
limit: PAGE,
|
|
3225
4524
|
offset,
|
|
3226
|
-
context:
|
|
4525
|
+
context: SYSTEM_CTX2
|
|
3227
4526
|
});
|
|
3228
4527
|
const rows = Array.isArray(batch) ? batch : [];
|
|
3229
4528
|
indexRows.push(...rows);
|
|
@@ -3237,7 +4536,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3237
4536
|
const want = desired.get(reqId);
|
|
3238
4537
|
const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
|
|
3239
4538
|
if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
|
|
3240
|
-
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4539
|
+
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3241
4540
|
deleted++;
|
|
3242
4541
|
continue;
|
|
3243
4542
|
}
|
|
@@ -3254,7 +4553,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3254
4553
|
approver,
|
|
3255
4554
|
organization_id: want.org,
|
|
3256
4555
|
created_at: now
|
|
3257
|
-
}, { context:
|
|
4556
|
+
}, { context: SYSTEM_CTX2 });
|
|
3258
4557
|
inserted++;
|
|
3259
4558
|
}
|
|
3260
4559
|
}
|
|
@@ -3304,7 +4603,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3304
4603
|
where,
|
|
3305
4604
|
fields: ["request_id"],
|
|
3306
4605
|
limit: _ApprovalService.APPROVER_INDEX_CAP,
|
|
3307
|
-
context:
|
|
4606
|
+
context: SYSTEM_CTX2
|
|
3308
4607
|
});
|
|
3309
4608
|
const list = Array.isArray(rows) ? rows : [];
|
|
3310
4609
|
if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
|
|
@@ -3315,6 +4614,88 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3315
4614
|
}
|
|
3316
4615
|
return [...new Set(list.map((r) => String(r.request_id)))];
|
|
3317
4616
|
}
|
|
4617
|
+
/**
|
|
4618
|
+
* The request ids this caller is a PARTICIPANT of, or `null` for a caller
|
|
4619
|
+
* who may see everything in scope (#3590).
|
|
4620
|
+
*
|
|
4621
|
+
* These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
|
|
4622
|
+
* approver-visibility rule spans several identity forms that RLS cannot model
|
|
4623
|
+
* cleanly, which is why it has to be expressed here. Until now only the
|
|
4624
|
+
* TENANT half of that rule was applied, so any authenticated user could read
|
|
4625
|
+
* any request in their tenant (and, once attachments derived their access
|
|
4626
|
+
* from the request, its files too). This adds the participant half.
|
|
4627
|
+
*
|
|
4628
|
+
* A participant is the submitter, a current approver, or someone who has
|
|
4629
|
+
* already acted on the request (a past approver whose slot has moved on, a
|
|
4630
|
+
* commenter). Admins with override authority keep the unrestricted view the
|
|
4631
|
+
* "all requests" console surface depends on.
|
|
4632
|
+
*
|
|
4633
|
+
* Keying on the concrete user id is sufficient rather than an approximation:
|
|
4634
|
+
* position/team/manager/field approvers are resolved to concrete user ids at
|
|
4635
|
+
* open time, and the `type:value` literal is only the fallback for a spec
|
|
4636
|
+
* that resolved to NOBODY — a slot no one can act on either way (`can_act`
|
|
4637
|
+
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
4638
|
+
* request from someone who could actually act on it.
|
|
4639
|
+
*/
|
|
4640
|
+
async visibleRequestIds(context, tenantOrg) {
|
|
4641
|
+
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
4642
|
+
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
4643
|
+
if (!uid2) return /* @__PURE__ */ new Set();
|
|
4644
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4645
|
+
const cap = _ApprovalService.APPROVER_INDEX_CAP;
|
|
4646
|
+
const add = (rows, key) => {
|
|
4647
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
4648
|
+
for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
|
|
4649
|
+
if (list.length >= cap) {
|
|
4650
|
+
this.logger?.warn?.(
|
|
4651
|
+
"[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
|
|
4652
|
+
{ cap, key }
|
|
4653
|
+
);
|
|
4654
|
+
}
|
|
4655
|
+
};
|
|
4656
|
+
try {
|
|
4657
|
+
for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
|
|
4658
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
4659
|
+
add(
|
|
4660
|
+
await this.engine.find("sys_approval_request", {
|
|
4661
|
+
where: { submitter_id: uid2, ...orgWhere },
|
|
4662
|
+
fields: ["id"],
|
|
4663
|
+
limit: cap,
|
|
4664
|
+
context: SYSTEM_CTX2
|
|
4665
|
+
}),
|
|
4666
|
+
"id"
|
|
4667
|
+
);
|
|
4668
|
+
add(
|
|
4669
|
+
await this.engine.find("sys_approval_action", {
|
|
4670
|
+
where: { actor_id: uid2 },
|
|
4671
|
+
fields: ["request_id"],
|
|
4672
|
+
limit: cap,
|
|
4673
|
+
context: SYSTEM_CTX2
|
|
4674
|
+
}),
|
|
4675
|
+
"request_id"
|
|
4676
|
+
);
|
|
4677
|
+
} catch (err) {
|
|
4678
|
+
this.logger?.warn?.("[approvals] participant-visibility probe failed", {
|
|
4679
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4680
|
+
});
|
|
4681
|
+
}
|
|
4682
|
+
return ids;
|
|
4683
|
+
}
|
|
4684
|
+
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
4685
|
+
applyVisibility(where, visible) {
|
|
4686
|
+
if (!visible) return true;
|
|
4687
|
+
if (visible.size === 0) return false;
|
|
4688
|
+
let allowed = [...visible];
|
|
4689
|
+
const current = where.id;
|
|
4690
|
+
if (typeof current === "string") allowed = allowed.filter((x) => x === current);
|
|
4691
|
+
else if (current && typeof current === "object" && Array.isArray(current.$in)) {
|
|
4692
|
+
const set = new Set(current.$in.map((v) => String(v)));
|
|
4693
|
+
allowed = allowed.filter((x) => set.has(x));
|
|
4694
|
+
}
|
|
4695
|
+
if (allowed.length === 0) return false;
|
|
4696
|
+
where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
|
|
4697
|
+
return true;
|
|
4698
|
+
}
|
|
3318
4699
|
async listRequests(filter, context) {
|
|
3319
4700
|
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
3320
4701
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
@@ -3323,10 +4704,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3323
4704
|
if (ids.length === 0) return [];
|
|
3324
4705
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3325
4706
|
}
|
|
4707
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
|
|
3326
4708
|
const findOpts = {
|
|
3327
4709
|
where,
|
|
3328
4710
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
3329
|
-
context:
|
|
4711
|
+
context: SYSTEM_CTX2
|
|
3330
4712
|
};
|
|
3331
4713
|
if (filter?.limit != null || filter?.offset != null) {
|
|
3332
4714
|
findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
|
|
@@ -3348,10 +4730,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3348
4730
|
if (ids.length === 0) return 0;
|
|
3349
4731
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3350
4732
|
}
|
|
4733
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
|
|
3351
4734
|
const countFn = this.engine.count;
|
|
3352
4735
|
if (typeof countFn === "function") {
|
|
3353
4736
|
try {
|
|
3354
|
-
const n = await countFn.call(this.engine, "sys_approval_request", { where, context:
|
|
4737
|
+
const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
|
|
3355
4738
|
if (typeof n === "number") return n;
|
|
3356
4739
|
} catch {
|
|
3357
4740
|
}
|
|
@@ -3360,11 +4743,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3360
4743
|
where,
|
|
3361
4744
|
fields: ["id"],
|
|
3362
4745
|
limit: ids ? Math.max(500, ids.length) : 500,
|
|
3363
|
-
context:
|
|
4746
|
+
context: SYSTEM_CTX2
|
|
3364
4747
|
});
|
|
3365
4748
|
return Array.isArray(rows) ? rows.length : 0;
|
|
3366
4749
|
}
|
|
4750
|
+
/**
|
|
4751
|
+
* Read the request a write path just changed, to echo back as its result.
|
|
4752
|
+
*
|
|
4753
|
+
* NOT participant-gated (#3590), deliberately: the operation authorized
|
|
4754
|
+
* itself by its own rule before writing, so re-asking "may you see this?"
|
|
4755
|
+
* for the echo answers a question that has already been settled — and would
|
|
4756
|
+
* answer it WRONG for a caller context that carries no `userId` (a
|
|
4757
|
+
* flow-driven resume, a service-to-service call), turning a successful write
|
|
4758
|
+
* into a `null` result. Gating belongs on the read API, not on an
|
|
4759
|
+
* operation's own return value.
|
|
4760
|
+
*/
|
|
4761
|
+
async readBackRequest(requestId, context) {
|
|
4762
|
+
return this.loadRequest(requestId, context, false);
|
|
4763
|
+
}
|
|
3367
4764
|
async getRequest(requestId, context) {
|
|
4765
|
+
return this.loadRequest(requestId, context, true);
|
|
4766
|
+
}
|
|
4767
|
+
async loadRequest(requestId, context, enforceVisibility) {
|
|
3368
4768
|
if (!requestId) return null;
|
|
3369
4769
|
const where = { id: requestId };
|
|
3370
4770
|
const tenantOrg = context?.organizationId ?? context?.tenantId;
|
|
@@ -3372,9 +4772,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3372
4772
|
const rows = await this.engine.find("sys_approval_request", {
|
|
3373
4773
|
where,
|
|
3374
4774
|
limit: 1,
|
|
3375
|
-
context:
|
|
4775
|
+
context: SYSTEM_CTX2
|
|
3376
4776
|
});
|
|
3377
4777
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
4778
|
+
if (enforceVisibility) {
|
|
4779
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
|
|
4780
|
+
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
4781
|
+
}
|
|
3378
4782
|
const row = rowFromRequest(rows[0]);
|
|
3379
4783
|
await this.enrichRows([row]);
|
|
3380
4784
|
await this.attachFlowSteps(row);
|
|
@@ -3401,7 +4805,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3401
4805
|
const acts = await this.engine.find("sys_approval_action", {
|
|
3402
4806
|
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3403
4807
|
limit: 1e3,
|
|
3404
|
-
context:
|
|
4808
|
+
context: SYSTEM_CTX2
|
|
3405
4809
|
});
|
|
3406
4810
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3407
4811
|
const snapshot = cfg?.__approverGroups;
|
|
@@ -3422,6 +4826,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3422
4826
|
});
|
|
3423
4827
|
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3424
4828
|
progress.need = progress.groups.length;
|
|
4829
|
+
const pendingGroups = {};
|
|
4830
|
+
for (const a of row.pending_approvers ?? []) {
|
|
4831
|
+
const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
|
|
4832
|
+
if (named.length) pendingGroups[a] = named;
|
|
4833
|
+
}
|
|
4834
|
+
if (Object.keys(pendingGroups).length) {
|
|
4835
|
+
row.pending_approver_groups = pendingGroups;
|
|
4836
|
+
}
|
|
3425
4837
|
}
|
|
3426
4838
|
row.decision_progress = progress;
|
|
3427
4839
|
} catch {
|
|
@@ -3433,8 +4845,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3433
4845
|
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
3434
4846
|
* still `pending` (position/team/manager approvers are already resolved to
|
|
3435
4847
|
* concrete user ids at open time, so a plain membership test is faithful).
|
|
3436
|
-
* `is_submitter` is a straight owner check.
|
|
3437
|
-
*
|
|
4848
|
+
* `is_submitter` is a straight owner check. `can_override` (#3424) is true for
|
|
4849
|
+
* a platform/tenant admin on a PENDING request — the recovery path for an
|
|
4850
|
+
* approval routed to an unstaffed position or to approvers who have all left;
|
|
4851
|
+
* clients OR it into the decision actions' `visible` gate so an admin can act
|
|
4852
|
+
* even when they hold no slot. System/tokenless contexts get a both-false
|
|
4853
|
+
* `can_act`/`is_submitter` block (system gets `can_override` too — it may act
|
|
4854
|
+
* on anything). Cheap + synchronous — safe on list reads.
|
|
3438
4855
|
*/
|
|
3439
4856
|
attachViewers(rows, context) {
|
|
3440
4857
|
const uid2 = context?.userId != null ? String(context.userId) : null;
|
|
@@ -3442,7 +4859,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3442
4859
|
const pending = row.pending_approvers ?? [];
|
|
3443
4860
|
row.viewer = {
|
|
3444
4861
|
can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
|
|
3445
|
-
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
|
|
4862
|
+
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
|
|
4863
|
+
can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
|
|
3446
4864
|
};
|
|
3447
4865
|
}
|
|
3448
4866
|
}
|
|
@@ -3489,7 +4907,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3489
4907
|
where: { request_id: requestId },
|
|
3490
4908
|
limit: 500,
|
|
3491
4909
|
orderBy: [{ field: "created_at", order: "asc" }],
|
|
3492
|
-
context:
|
|
4910
|
+
context: SYSTEM_CTX2
|
|
3493
4911
|
});
|
|
3494
4912
|
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
3495
4913
|
const names = await this.resolveUserNames(
|
|
@@ -3501,6 +4919,36 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3501
4919
|
}
|
|
3502
4920
|
return actions;
|
|
3503
4921
|
}
|
|
4922
|
+
/**
|
|
4923
|
+
* `IFileAccessDelegate` — may this caller download a decision attachment?
|
|
4924
|
+
* (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
|
|
4925
|
+
*
|
|
4926
|
+
* A file referenced by `sys_approval_action.attachments` is owned by that
|
|
4927
|
+
* audit row, so the storage service would otherwise authorize the download by
|
|
4928
|
+
* testing whether the caller can READ the row. It cannot: `sys_approval_action`
|
|
4929
|
+
* is deliberately closed to ordinary approver positions, so that test denies
|
|
4930
|
+
* the very approver the attachment was filed for.
|
|
4931
|
+
*
|
|
4932
|
+
* The rule that actually governs seeing a decision is the one `listActions`
|
|
4933
|
+
* applies — can the caller see the PARENT REQUEST? — so this reuses it
|
|
4934
|
+
* exactly, rather than inventing a second, looser rule for the bytes. Fails
|
|
4935
|
+
* closed on any error.
|
|
4936
|
+
*/
|
|
4937
|
+
async authorizeFileRead(actionId, context) {
|
|
4938
|
+
if (!actionId) return false;
|
|
4939
|
+
try {
|
|
4940
|
+
const rows = await this.engine.find("sys_approval_action", {
|
|
4941
|
+
where: { id: actionId },
|
|
4942
|
+
limit: 1,
|
|
4943
|
+
context: SYSTEM_CTX2
|
|
4944
|
+
});
|
|
4945
|
+
const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
|
|
4946
|
+
if (!requestId) return false;
|
|
4947
|
+
return !!await this.getRequest(String(requestId), context);
|
|
4948
|
+
} catch {
|
|
4949
|
+
return false;
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
3504
4952
|
};
|
|
3505
4953
|
/** Window the approver-index probe — pending queues live far below this. */
|
|
3506
4954
|
_ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
@@ -3695,6 +5143,8 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
3695
5143
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3696
5144
|
const pending = await pendingRequestFor(engine, object, id);
|
|
3697
5145
|
if (!pending) return;
|
|
5146
|
+
const writerRun = ctx?.provenance?.flowRunId;
|
|
5147
|
+
if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
|
|
3698
5148
|
const config = parseJson2(pending.node_config_json, {});
|
|
3699
5149
|
if (config?.lockRecord === false) return;
|
|
3700
5150
|
const mirror = config?.approvalStatusField;
|
|
@@ -3751,7 +5201,22 @@ import {
|
|
|
3751
5201
|
getApprovalNodeConfigJsonSchema,
|
|
3752
5202
|
APPROVAL_NODE_TYPE
|
|
3753
5203
|
} from "@objectstack/spec/automation";
|
|
3754
|
-
var
|
|
5204
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
5205
|
+
function nestVariables(variables) {
|
|
5206
|
+
const vars = {};
|
|
5207
|
+
for (const [key, value] of variables) {
|
|
5208
|
+
const segs = key.split(".");
|
|
5209
|
+
let cursor = vars;
|
|
5210
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
5211
|
+
if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
|
|
5212
|
+
cursor[segs[i]] = {};
|
|
5213
|
+
}
|
|
5214
|
+
cursor = cursor[segs[i]];
|
|
5215
|
+
}
|
|
5216
|
+
cursor[segs[segs.length - 1]] = value;
|
|
5217
|
+
}
|
|
5218
|
+
return vars;
|
|
5219
|
+
}
|
|
3755
5220
|
function registerApprovalNode(automation, service, logger) {
|
|
3756
5221
|
automation.registerNodeExecutor({
|
|
3757
5222
|
type: APPROVAL_NODE_TYPE,
|
|
@@ -3767,6 +5232,15 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3767
5232
|
// Human decision: the run suspends here awaiting an external reply.
|
|
3768
5233
|
supportsPause: true,
|
|
3769
5234
|
isAsync: true,
|
|
5235
|
+
// #3801: this pause is NOT resumable through the generic run-resume
|
|
5236
|
+
// route. Continuing an approval is a side effect of a DECISION, and the
|
|
5237
|
+
// decision is the thing that must be authorized (the approver slate),
|
|
5238
|
+
// recorded (`sys_approval_action`) and mirrored (the status field) —
|
|
5239
|
+
// all of which lives in `ApprovalService.decide`. The engine now refuses
|
|
5240
|
+
// any resume of an approval suspension that does not carry the service's
|
|
5241
|
+
// in-process marker, so "decide via the approvals API, never a raw engine
|
|
5242
|
+
// resume" is enforced rather than merely documented.
|
|
5243
|
+
resumeAuthority: "service",
|
|
3770
5244
|
// Publish the node's config contract (ADR-0018 §configSchema) so the
|
|
3771
5245
|
// Studio flow designer renders the Approval property form from the engine
|
|
3772
5246
|
// rather than a hardcoded client form — the engine owns the shape.
|
|
@@ -3800,13 +5274,28 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3800
5274
|
nodeLabel: typeof node.label === "string" ? node.label : void 0,
|
|
3801
5275
|
submitterId: context?.userId ?? null,
|
|
3802
5276
|
record,
|
|
3803
|
-
organizationId: context?.organizationId ?? context?.tenantId ?? null
|
|
5277
|
+
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
5278
|
+
// #3447 P2: flow variables (nested, as CEL conditions see them) — the
|
|
5279
|
+
// `vars.*` root for `expression` approvers; `record` above doubles as
|
|
5280
|
+
// their `trigger.*` snapshot root.
|
|
5281
|
+
variables: nestVariables(variables)
|
|
3804
5282
|
}, {
|
|
3805
|
-
...
|
|
5283
|
+
...SYSTEM_CTX3,
|
|
3806
5284
|
userId: context?.userId,
|
|
3807
5285
|
organizationId: context?.organizationId,
|
|
3808
5286
|
tenantId: context?.tenantId
|
|
3809
5287
|
});
|
|
5288
|
+
if ("autoApproved" in request) {
|
|
5289
|
+
logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
|
|
5290
|
+
node: node.id,
|
|
5291
|
+
run: String(runId)
|
|
5292
|
+
});
|
|
5293
|
+
return {
|
|
5294
|
+
success: true,
|
|
5295
|
+
branchLabel: "approve",
|
|
5296
|
+
output: { decision: "approve", autoApproved: true }
|
|
5297
|
+
};
|
|
5298
|
+
}
|
|
3810
5299
|
logger?.info?.("[approvals] approval node suspended run", {
|
|
3811
5300
|
node: node.id,
|
|
3812
5301
|
request: request.id,
|
|
@@ -3892,7 +5381,21 @@ var ApprovalsServicePlugin = class {
|
|
|
3892
5381
|
this.service = new ApprovalService({
|
|
3893
5382
|
engine,
|
|
3894
5383
|
logger: ctx.logger,
|
|
3895
|
-
publicBaseUrl: this.options.publicBaseUrl
|
|
5384
|
+
publicBaseUrl: this.options.publicBaseUrl,
|
|
5385
|
+
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
5386
|
+
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
5387
|
+
// service resolves its posture during its own start, which may not have
|
|
5388
|
+
// run yet; an unresolvable posture reads as "unknown" and the guard
|
|
5389
|
+
// stands down rather than refusing a legitimate flow on a minimal stack.
|
|
5390
|
+
tenancyPosture: () => {
|
|
5391
|
+
try {
|
|
5392
|
+
const tenancy = ctx.getService("tenancy");
|
|
5393
|
+
const posture = tenancy?.posture;
|
|
5394
|
+
return typeof posture === "string" && posture ? posture : void 0;
|
|
5395
|
+
} catch {
|
|
5396
|
+
return void 0;
|
|
5397
|
+
}
|
|
5398
|
+
}
|
|
3896
5399
|
});
|
|
3897
5400
|
if (!this.options.disableAutoHooks) {
|
|
3898
5401
|
try {
|
|
@@ -3918,12 +5421,23 @@ var ApprovalsServicePlugin = class {
|
|
|
3918
5421
|
if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
|
|
3919
5422
|
const svc = this.service;
|
|
3920
5423
|
const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
|
|
3921
|
-
|
|
3922
|
-
await
|
|
3923
|
-
|
|
5424
|
+
const sweep = async () => {
|
|
5425
|
+
const results = await Promise.allSettled([
|
|
5426
|
+
svc.runEscalations(),
|
|
5427
|
+
svc.releaseDeadRunRequests()
|
|
5428
|
+
]);
|
|
5429
|
+
for (const r of results) {
|
|
5430
|
+
if (r.status === "rejected") {
|
|
5431
|
+
ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
|
|
5432
|
+
error: r.reason?.message ?? String(r.reason)
|
|
5433
|
+
});
|
|
5434
|
+
}
|
|
5435
|
+
}
|
|
5436
|
+
};
|
|
5437
|
+
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
3924
5438
|
this.escalationJobScheduled = true;
|
|
3925
|
-
void
|
|
3926
|
-
ctx.logger.warn?.("[approvals] boot
|
|
5439
|
+
void sweep().catch((err) => {
|
|
5440
|
+
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
3927
5441
|
});
|
|
3928
5442
|
ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
|
|
3929
5443
|
} catch {
|