@objectstack/plugin-approvals 17.1.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1288 -0
- package/dist/index.d.mts +1576 -587
- package/dist/index.d.ts +1576 -587
- package/dist/index.js +1145 -92
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1151 -93
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -15
package/dist/index.mjs
CHANGED
|
@@ -29,7 +29,7 @@ var init_en_objects_generated = __esm({
|
|
|
29
29
|
},
|
|
30
30
|
organization_id: {
|
|
31
31
|
label: "Organization",
|
|
32
|
-
help: "
|
|
32
|
+
help: "Organization of the record this request is about (falls back to the acting context when the record has none)"
|
|
33
33
|
},
|
|
34
34
|
process_name: {
|
|
35
35
|
label: "Source",
|
|
@@ -55,7 +55,15 @@ var init_en_objects_generated = __esm({
|
|
|
55
55
|
approved: "Approved",
|
|
56
56
|
rejected: "Rejected",
|
|
57
57
|
recalled: "Recalled",
|
|
58
|
-
returned: "Returned"
|
|
58
|
+
returned: "Returned",
|
|
59
|
+
cancelled: "Cancelled"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
cancel_reason: {
|
|
63
|
+
label: "Cancellation Reason",
|
|
64
|
+
help: "Why the platform voided this request (set only when the status is cancelled)",
|
|
65
|
+
options: {
|
|
66
|
+
record_deleted: "Related record deleted"
|
|
59
67
|
}
|
|
60
68
|
},
|
|
61
69
|
current_step: {
|
|
@@ -236,7 +244,8 @@ var init_en_objects_generated = __esm({
|
|
|
236
244
|
comment: "Comment",
|
|
237
245
|
revise: "Revise",
|
|
238
246
|
resubmit: "Resubmit",
|
|
239
|
-
ooo_substitute: "Out-of-Office Substitution"
|
|
247
|
+
ooo_substitute: "Out-of-Office Substitution",
|
|
248
|
+
cancel: "Cancel"
|
|
240
249
|
}
|
|
241
250
|
},
|
|
242
251
|
actor_id: {
|
|
@@ -350,7 +359,7 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
350
359
|
},
|
|
351
360
|
organization_id: {
|
|
352
361
|
label: "\u7EC4\u7EC7",
|
|
353
|
-
help: "\
|
|
362
|
+
help: "\u8BE5\u8BF7\u6C42\u6240\u6D89\u8BB0\u5F55\u6240\u5C5E\u7684\u7EC4\u7EC7\uFF08\u8BB0\u5F55\u65E0\u7EC4\u7EC7\u65F6\u56DE\u9000\u5230\u64CD\u4F5C\u4E0A\u4E0B\u6587\uFF09"
|
|
354
363
|
},
|
|
355
364
|
process_name: {
|
|
356
365
|
label: "\u6765\u6E90",
|
|
@@ -376,7 +385,15 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
376
385
|
approved: "\u5DF2\u6279\u51C6",
|
|
377
386
|
rejected: "\u5DF2\u62D2\u7EDD",
|
|
378
387
|
recalled: "\u5DF2\u64A4\u56DE",
|
|
379
|
-
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
|
|
388
|
+
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539",
|
|
389
|
+
cancelled: "\u5DF2\u4F5C\u5E9F"
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
cancel_reason: {
|
|
393
|
+
label: "\u4F5C\u5E9F\u539F\u56E0",
|
|
394
|
+
help: "\u5E73\u53F0\u4F5C\u5E9F\u8BE5\u5BA1\u6279\u5355\u7684\u539F\u56E0(\u4EC5\u5728\u72B6\u6001\u4E3A\u300C\u5DF2\u4F5C\u5E9F\u300D\u65F6\u5199\u5165)",
|
|
395
|
+
options: {
|
|
396
|
+
record_deleted: "\u5173\u8054\u8BB0\u5F55\u5DF2\u5220\u9664"
|
|
380
397
|
}
|
|
381
398
|
},
|
|
382
399
|
current_step: {
|
|
@@ -557,7 +574,8 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
557
574
|
comment: "\u8BC4\u8BBA",
|
|
558
575
|
revise: "\u9000\u56DE\u4FEE\u6539",
|
|
559
576
|
resubmit: "\u91CD\u65B0\u63D0\u4EA4",
|
|
560
|
-
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
|
|
577
|
+
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E",
|
|
578
|
+
cancel: "\u4F5C\u5E9F"
|
|
561
579
|
}
|
|
562
580
|
},
|
|
563
581
|
actor_id: {
|
|
@@ -671,7 +689,7 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
671
689
|
},
|
|
672
690
|
organization_id: {
|
|
673
691
|
label: "\u7D44\u7E54",
|
|
674
|
-
help: "\u3053\u306E\
|
|
692
|
+
help: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u5BFE\u8C61\u30EC\u30B3\u30FC\u30C9\u304C\u5C5E\u3059\u308B\u7D44\u7E54\uFF08\u30EC\u30B3\u30FC\u30C9\u306B\u7D44\u7E54\u304C\u306A\u3044\u5834\u5408\u306F\u64CD\u4F5C\u30B3\u30F3\u30C6\u30AD\u30B9\u30C8\u306B\u30D5\u30A9\u30FC\u30EB\u30D0\u30C3\u30AF\uFF09"
|
|
675
693
|
},
|
|
676
694
|
process_name: {
|
|
677
695
|
label: "\u30BD\u30FC\u30B9",
|
|
@@ -697,7 +715,15 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
697
715
|
approved: "\u627F\u8A8D\u6E08\u307F",
|
|
698
716
|
rejected: "\u5374\u4E0B\u6E08\u307F",
|
|
699
717
|
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
|
|
700
|
-
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
|
|
718
|
+
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F",
|
|
719
|
+
cancelled: "\u7121\u52B9\u5316\u6E08\u307F"
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
cancel_reason: {
|
|
723
|
+
label: "\u7121\u52B9\u5316\u7406\u7531",
|
|
724
|
+
help: "\u30D7\u30E9\u30C3\u30C8\u30D5\u30A9\u30FC\u30E0\u304C\u3053\u306E\u7533\u8ACB\u3092\u7121\u52B9\u5316\u3057\u305F\u7406\u7531(\u30B9\u30C6\u30FC\u30BF\u30B9\u304C\u7121\u52B9\u5316\u6E08\u307F\u306E\u5834\u5408\u306E\u307F\u8A2D\u5B9A)",
|
|
725
|
+
options: {
|
|
726
|
+
record_deleted: "\u95A2\u9023\u30EC\u30B3\u30FC\u30C9\u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F"
|
|
701
727
|
}
|
|
702
728
|
},
|
|
703
729
|
current_step: {
|
|
@@ -878,7 +904,8 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
878
904
|
comment: "\u30B3\u30E1\u30F3\u30C8",
|
|
879
905
|
revise: "\u5DEE\u3057\u623B\u3057",
|
|
880
906
|
resubmit: "\u518D\u63D0\u51FA",
|
|
881
|
-
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
|
|
907
|
+
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406",
|
|
908
|
+
cancel: "\u7121\u52B9\u5316"
|
|
882
909
|
}
|
|
883
910
|
},
|
|
884
911
|
actor_id: {
|
|
@@ -992,7 +1019,7 @@ var init_es_ES_objects_generated = __esm({
|
|
|
992
1019
|
},
|
|
993
1020
|
organization_id: {
|
|
994
1021
|
label: "Organizaci\xF3n",
|
|
995
|
-
help: "
|
|
1022
|
+
help: "Organizaci\xF3n del registro al que se refiere esta solicitud (recurre al contexto del actor cuando el registro no tiene ninguna)."
|
|
996
1023
|
},
|
|
997
1024
|
process_name: {
|
|
998
1025
|
label: "Origen",
|
|
@@ -1018,7 +1045,15 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1018
1045
|
approved: "Aprobada",
|
|
1019
1046
|
rejected: "Rechazada",
|
|
1020
1047
|
recalled: "Retirada",
|
|
1021
|
-
returned: "Devuelta para revisi\xF3n"
|
|
1048
|
+
returned: "Devuelta para revisi\xF3n",
|
|
1049
|
+
cancelled: "Anulada"
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
cancel_reason: {
|
|
1053
|
+
label: "Motivo de anulaci\xF3n",
|
|
1054
|
+
help: "Por qu\xE9 la plataforma anul\xF3 esta solicitud (solo se establece cuando el estado es Anulada)",
|
|
1055
|
+
options: {
|
|
1056
|
+
record_deleted: "Registro relacionado eliminado"
|
|
1022
1057
|
}
|
|
1023
1058
|
},
|
|
1024
1059
|
current_step: {
|
|
@@ -1199,7 +1234,8 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1199
1234
|
comment: "Comentario",
|
|
1200
1235
|
revise: "Devoluci\xF3n",
|
|
1201
1236
|
resubmit: "Reenv\xEDo",
|
|
1202
|
-
ooo_substitute: "Sustituci\xF3n por ausencia"
|
|
1237
|
+
ooo_substitute: "Sustituci\xF3n por ausencia",
|
|
1238
|
+
cancel: "Anulaci\xF3n"
|
|
1203
1239
|
}
|
|
1204
1240
|
},
|
|
1205
1241
|
actor_id: {
|
|
@@ -1297,12 +1333,104 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1297
1333
|
}
|
|
1298
1334
|
});
|
|
1299
1335
|
|
|
1336
|
+
// src/translations/zh-CN.source-hashes.generated.ts
|
|
1337
|
+
var zhCNGeneratedSourceHashes;
|
|
1338
|
+
var init_zh_CN_source_hashes_generated = __esm({
|
|
1339
|
+
"src/translations/zh-CN.source-hashes.generated.ts"() {
|
|
1340
|
+
"use strict";
|
|
1341
|
+
zhCNGeneratedSourceHashes = {
|
|
1342
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1343
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1344
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1345
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1346
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1347
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// src/translations/ja-JP.source-hashes.generated.ts
|
|
1353
|
+
var jaJPGeneratedSourceHashes;
|
|
1354
|
+
var init_ja_JP_source_hashes_generated = __esm({
|
|
1355
|
+
"src/translations/ja-JP.source-hashes.generated.ts"() {
|
|
1356
|
+
"use strict";
|
|
1357
|
+
jaJPGeneratedSourceHashes = {
|
|
1358
|
+
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
|
|
1359
|
+
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
|
|
1360
|
+
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
|
|
1361
|
+
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
|
|
1362
|
+
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
|
|
1363
|
+
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
|
|
1364
|
+
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
|
|
1365
|
+
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
|
|
1366
|
+
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
|
|
1367
|
+
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
|
|
1368
|
+
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
|
|
1369
|
+
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
|
|
1370
|
+
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
|
|
1371
|
+
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
|
|
1372
|
+
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
|
|
1373
|
+
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
|
|
1374
|
+
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
|
|
1375
|
+
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
|
|
1376
|
+
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
|
|
1377
|
+
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
|
|
1378
|
+
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
|
|
1379
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1380
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1381
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1382
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1383
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1384
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
// src/translations/es-ES.source-hashes.generated.ts
|
|
1390
|
+
var esESGeneratedSourceHashes;
|
|
1391
|
+
var init_es_ES_source_hashes_generated = __esm({
|
|
1392
|
+
"src/translations/es-ES.source-hashes.generated.ts"() {
|
|
1393
|
+
"use strict";
|
|
1394
|
+
esESGeneratedSourceHashes = {
|
|
1395
|
+
"objects.sys_approval_action.fields.actor_id.label": "b155813f8a7f06e3",
|
|
1396
|
+
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
|
|
1397
|
+
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
|
|
1398
|
+
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
|
|
1399
|
+
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
|
|
1400
|
+
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
|
|
1401
|
+
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
|
|
1402
|
+
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
|
|
1403
|
+
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
|
|
1404
|
+
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
|
|
1405
|
+
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
|
|
1406
|
+
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
|
|
1407
|
+
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
|
|
1408
|
+
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
|
|
1409
|
+
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
|
|
1410
|
+
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
|
|
1411
|
+
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
|
|
1412
|
+
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
|
|
1413
|
+
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
|
|
1414
|
+
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
|
|
1415
|
+
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
|
|
1416
|
+
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
|
|
1417
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1418
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1419
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1420
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1421
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1422
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1300
1427
|
// src/translations/index.ts
|
|
1301
1428
|
var translations_exports = {};
|
|
1302
1429
|
__export(translations_exports, {
|
|
1303
1430
|
ApprovalsTranslations: () => ApprovalsTranslations
|
|
1304
1431
|
});
|
|
1305
|
-
|
|
1432
|
+
import { withSourceFallback } from "@objectstack/platform-objects/apps";
|
|
1433
|
+
var enSource, ApprovalsTranslations;
|
|
1306
1434
|
var init_translations = __esm({
|
|
1307
1435
|
"src/translations/index.ts"() {
|
|
1308
1436
|
"use strict";
|
|
@@ -1310,18 +1438,27 @@ var init_translations = __esm({
|
|
|
1310
1438
|
init_zh_CN_objects_generated();
|
|
1311
1439
|
init_ja_JP_objects_generated();
|
|
1312
1440
|
init_es_ES_objects_generated();
|
|
1441
|
+
init_zh_CN_source_hashes_generated();
|
|
1442
|
+
init_ja_JP_source_hashes_generated();
|
|
1443
|
+
init_es_ES_source_hashes_generated();
|
|
1444
|
+
enSource = { objects: enObjects };
|
|
1313
1445
|
ApprovalsTranslations = {
|
|
1314
|
-
en:
|
|
1315
|
-
"zh-CN": { objects: zhCNObjects },
|
|
1316
|
-
"ja-JP": { objects: jaJPObjects },
|
|
1317
|
-
"es-ES": { objects: esESObjects }
|
|
1446
|
+
en: enSource,
|
|
1447
|
+
"zh-CN": withSourceFallback({ objects: zhCNObjects }, enSource, void 0, zhCNGeneratedSourceHashes),
|
|
1448
|
+
"ja-JP": withSourceFallback({ objects: jaJPObjects }, enSource, void 0, jaJPGeneratedSourceHashes),
|
|
1449
|
+
"es-ES": withSourceFallback({ objects: esESObjects }, enSource, void 0, esESGeneratedSourceHashes)
|
|
1318
1450
|
};
|
|
1319
1451
|
}
|
|
1320
1452
|
});
|
|
1321
1453
|
|
|
1322
1454
|
// src/sys-approval-request.object.ts
|
|
1323
1455
|
import { ObjectSchema, Field } from "@objectstack/spec/data";
|
|
1324
|
-
import {
|
|
1456
|
+
import {
|
|
1457
|
+
APPROVAL_STATUSES,
|
|
1458
|
+
APPROVAL_STATUS_LABELS,
|
|
1459
|
+
APPROVAL_CANCEL_REASONS,
|
|
1460
|
+
APPROVAL_CANCEL_REASON_LABELS
|
|
1461
|
+
} from "@objectstack/spec/contracts";
|
|
1325
1462
|
var SysApprovalRequest = ObjectSchema.create({
|
|
1326
1463
|
name: "sys_approval_request",
|
|
1327
1464
|
label: "Approval Request",
|
|
@@ -1368,7 +1505,10 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1368
1505
|
label: "Completed",
|
|
1369
1506
|
data: { provider: "object", object: "sys_approval_request" },
|
|
1370
1507
|
columns: ["process_name", "object_name", "record_id", "status", "submitter_id", "completed_at"],
|
|
1371
|
-
|
|
1508
|
+
// Every terminal state, `cancelled` included (#13568) — a platform-voided
|
|
1509
|
+
// request is kept as audit evidence, and a terminal state absent from the
|
|
1510
|
+
// only curated terminal view is evidence nobody can find.
|
|
1511
|
+
filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled", "cancelled"] }],
|
|
1372
1512
|
sort: [{ field: "completed_at", order: "desc" }],
|
|
1373
1513
|
pagination: { pageSize: 25 }
|
|
1374
1514
|
},
|
|
@@ -1384,46 +1524,40 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1384
1524
|
},
|
|
1385
1525
|
fields: {
|
|
1386
1526
|
id: Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1393
|
-
// whatever filter each surface happens to apply rather than by the data.
|
|
1527
|
+
// [#10101, the cloud#1395 Option A ruling] The SUBJECT record's
|
|
1528
|
+
// organization, with the acting context as fallback — resolved by the
|
|
1529
|
+
// SHARED platform-row resolver (`resolveRecordOrganizationField`,
|
|
1530
|
+
// `@objectstack/metadata-core`) in `openNodeRequest`, the row's only
|
|
1531
|
+
// writer. The same `requestOrg` stamps `sys_approval_action` and the
|
|
1532
|
+
// `sys_approval_approver` index, so all three move together.
|
|
1394
1533
|
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
// measured 27 of 27 rows org-less
|
|
1400
|
-
//
|
|
1534
|
+
// Why subject-first: an approval request is read through the organization
|
|
1535
|
+
// wall by the approvals inbox, and the acting context is NULL on every
|
|
1536
|
+
// schedule / time-relative / api triggered run (none carries a tenant, by
|
|
1537
|
+
// construction). Stamped from the actor alone — the pre-#10101 behaviour,
|
|
1538
|
+
// measured on cloud#1395 as 27 of 27 rows org-less on a walled HotCRM SaaS
|
|
1539
|
+
// boot — such a request LOCKED the record it was about while being
|
|
1540
|
+
// invisible in every inbox, its owner's included. Subject-first is also
|
|
1541
|
+
// what `sys_audit_log`'s writer already did (#8707 honouring #8287's
|
|
1542
|
+
// ruling), so an approval row and an audit row about the same record now
|
|
1543
|
+
// land behind the same wall instead of two.
|
|
1401
1544
|
//
|
|
1402
|
-
//
|
|
1403
|
-
// `
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
// writers read the actor; a third reads the subject. That disagreement is
|
|
1408
|
-
// the defect.
|
|
1409
|
-
//
|
|
1410
|
-
// Which of the two a side-table row should follow is an open contract
|
|
1411
|
-
// question on cloud#1395 — the audit resolver is scope-pinned to audit
|
|
1412
|
-
// stamping by the #8778 ruling, so this writer needs its own. The same
|
|
1413
|
-
// `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
|
|
1414
|
-
// so all three move together.
|
|
1545
|
+
// The `sys_api_key` divergence is deliberate and preserved: its
|
|
1546
|
+
// `tenancy.organizationField: 'active_organization_id'` (stamp-only,
|
|
1547
|
+
// #8778) wins limb 0 of the shared resolver, while the credential table
|
|
1548
|
+
// itself stays unwalled (`tenancy.enabled: false`) — who a row is ABOUT
|
|
1549
|
+
// and what an object is WALLED by remain different questions.
|
|
1415
1550
|
organization_id: Field.lookup("sys_organization", {
|
|
1416
1551
|
label: "Organization",
|
|
1417
1552
|
required: false,
|
|
1418
1553
|
group: "System",
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
|
|
1426
|
-
description: "Tenant that owns this approval request (propagated from submitter context)"
|
|
1554
|
+
// Reworded with the #10101 write-side fix (was "Tenant that owns this
|
|
1555
|
+
// approval request (propagated from submitter context)" — it claimed a
|
|
1556
|
+
// propagation that measurably did not happen, and said "Tenant" where
|
|
1557
|
+
// ADR-0120 §Terminology requires "organization"). Extracted into the
|
|
1558
|
+
// generated i18n bundles as `help`; the four locales regenerate in the
|
|
1559
|
+
// same pass.
|
|
1560
|
+
description: "Organization of the record this request is about (falls back to the acting context when the record has none)"
|
|
1427
1561
|
}),
|
|
1428
1562
|
process_name: Field.text({
|
|
1429
1563
|
label: "Source",
|
|
@@ -1442,7 +1576,38 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1442
1576
|
label: "Record ID",
|
|
1443
1577
|
required: true,
|
|
1444
1578
|
maxLength: 100,
|
|
1445
|
-
|
|
1579
|
+
// [#11386] The id half of this object's pointer pair (ADR-0052 §5),
|
|
1580
|
+
// adopting the #11339 carrier. VERIFIED for THIS object: the pair is not
|
|
1581
|
+
// decoration but the key the approval machinery QUERIES ON —
|
|
1582
|
+
// `approval-service.ts` finds a record's pending request with
|
|
1583
|
+
// `where: { object_name, record_id, status: 'pending' }`, and
|
|
1584
|
+
// `lifecycle-hooks.ts` holds the record LOCK on the same pair
|
|
1585
|
+
// (single-record and `$in` batch forms). `submit()` writes it from
|
|
1586
|
+
// `input.object` / `input.recordId`, so a stored value is always a
|
|
1587
|
+
// record id of the object the sibling names.
|
|
1588
|
+
//
|
|
1589
|
+
// Consequence of declaring, sharper here than elsewhere: a seeded
|
|
1590
|
+
// request whose `record_id` stayed a verbatim natural key locked
|
|
1591
|
+
// NOTHING and appeared under no record — it looked like a pending
|
|
1592
|
+
// approval while being invisible to both queries that give the row its
|
|
1593
|
+
// meaning. That is now a loud seed-time refusal. Both halves are
|
|
1594
|
+
// `required: true`, so the un-addressable case (id half authored, type
|
|
1595
|
+
// half empty) is already unreachable on this object.
|
|
1596
|
+
group: "Target",
|
|
1597
|
+
//
|
|
1598
|
+
// ⚠️ ORDERING CONSTRAINT — this id half is `required: true`, and that
|
|
1599
|
+
// makes it ORDER-DEPENDENT in seeds even though a pointer pair
|
|
1600
|
+
// contributes no static ordering edge (#11674, measured against the real
|
|
1601
|
+
// engine in `packages/objectql/src/engine-seed-required-deferral.test.ts`):
|
|
1602
|
+
// the seed loader defers an unresolvable reference by DELETING the column
|
|
1603
|
+
// from the pass-1 insert, required-validation rejects that row, and pass 2
|
|
1604
|
+
// is then left with no row to back-fill. So the pass-2 healing that makes
|
|
1605
|
+
// an OPTIONAL id half order-independent (`sys_audit_log`) does not reach
|
|
1606
|
+
// this one. ⇒ SEED THE TARGET DATASET FIRST. The failure if you do not is
|
|
1607
|
+
// loud in three places — a write error naming this column, a
|
|
1608
|
+
// dropped-deferral error, and `success: false` — and since #11674 the
|
|
1609
|
+
// loader also WARNS at load time, before the engine rejects the row.
|
|
1610
|
+
referenceVia: "object_name"
|
|
1446
1611
|
}),
|
|
1447
1612
|
submitter_id: Field.lookup("sys_user", {
|
|
1448
1613
|
label: "Submitter",
|
|
@@ -1470,6 +1635,29 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1470
1635
|
group: "State"
|
|
1471
1636
|
}
|
|
1472
1637
|
),
|
|
1638
|
+
// [#13568] Why a `cancelled` request was cancelled — the machine-readable
|
|
1639
|
+
// half of the maintainer's 2026-08-31 ruling ("状态转 cancelled + 机器可读
|
|
1640
|
+
// 原因"). Derived from `APPROVAL_CANCEL_REASONS`, never re-typed, on the
|
|
1641
|
+
// same #3786 rule the `status` column above follows.
|
|
1642
|
+
//
|
|
1643
|
+
// On the ROW rather than on the `sys_approval_action` audit entry, because
|
|
1644
|
+
// the readers are the inbox and the tombstone presentation, which page the
|
|
1645
|
+
// request table: making them join the append-only action log to learn why a
|
|
1646
|
+
// row they already hold is cancelled buys nothing and would make the reason
|
|
1647
|
+
// unavailable to a plain list view. The action row still records the event
|
|
1648
|
+
// (`action: 'cancel'`); this column records the STATE.
|
|
1649
|
+
//
|
|
1650
|
+
// Null on every non-cancelled row, and on rows written before the column
|
|
1651
|
+
// existed — "not recorded", never "cancelled for no reason".
|
|
1652
|
+
cancel_reason: Field.select(
|
|
1653
|
+
APPROVAL_CANCEL_REASONS.map((value) => ({ value, label: APPROVAL_CANCEL_REASON_LABELS[value] })),
|
|
1654
|
+
{
|
|
1655
|
+
label: "Cancellation Reason",
|
|
1656
|
+
required: false,
|
|
1657
|
+
description: "Why the platform voided this request (set only when the status is cancelled)",
|
|
1658
|
+
group: "State"
|
|
1659
|
+
}
|
|
1660
|
+
),
|
|
1473
1661
|
current_step: Field.text({
|
|
1474
1662
|
label: "Current Step",
|
|
1475
1663
|
required: false,
|
|
@@ -1489,6 +1677,22 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1489
1677
|
description: "Comma-separated user ids who can act on the current step",
|
|
1490
1678
|
group: "State"
|
|
1491
1679
|
}),
|
|
1680
|
+
// The module docstring above used to justify this column with "used by
|
|
1681
|
+
// notifications so they can render before the record is locked or
|
|
1682
|
+
// changed". That consumer does not exist, and the claim was cited as the
|
|
1683
|
+
// reason the column holds a FULL row before anyone checked it. Measured
|
|
1684
|
+
// against every `this.notify(...)` call site in `approval-service.ts` —
|
|
1685
|
+
// all 12 of them: each passes `{ title, message, actionUrl }` (two also
|
|
1686
|
+
// `actions`), built from `object_name` / `record_id` and the caller's
|
|
1687
|
+
// comment. None reads this column or the parsed `payload`. The real
|
|
1688
|
+
// readers are the serve path (`rowFromRequest` -> `payload`, redacted per
|
|
1689
|
+
// reader), the decide-time approver re-resolution and the org backfill,
|
|
1690
|
+
// both under SYSTEM_CTX, and the free-text predicate.
|
|
1691
|
+
//
|
|
1692
|
+
// The `description` below is deliberately UNCHANGED: it is accurate, and
|
|
1693
|
+
// it is extracted into the four generated i18n bundles as `help`. The
|
|
1694
|
+
// false sentence lived only in the JSDoc above, which is not extracted —
|
|
1695
|
+
// so this correction moves no translation leaf.
|
|
1492
1696
|
payload_json: Field.textarea({
|
|
1493
1697
|
label: "Snapshot",
|
|
1494
1698
|
required: false,
|
|
@@ -1559,11 +1763,14 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1559
1763
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1560
1764
|
// (the caller is a current pending approver — same check the service
|
|
1561
1765
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1562
|
-
// submitter actions on `record.viewer.is_submitter`. The
|
|
1563
|
-
// (approve/reject/reassign
|
|
1564
|
-
//
|
|
1766
|
+
// submitter actions on `record.viewer.is_submitter`. The four levers the
|
|
1767
|
+
// #3424 override covers (approve/reject/reassign, and recall since #12716)
|
|
1768
|
+
// additionally OR in `record.viewer.can_override`
|
|
1769
|
+
// so a platform/tenant admin can rescue a request routed to an
|
|
1565
1770
|
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
1566
|
-
// approving, rejecting,
|
|
1771
|
+
// approving, rejecting, reassigning it to a real approver, or recalling it
|
|
1772
|
+
// (the lever that releases the record without recording a decision nobody
|
|
1773
|
+
// made). `viewer` is
|
|
1567
1774
|
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1568
1775
|
// closed.
|
|
1569
1776
|
//
|
|
@@ -1699,8 +1906,13 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1699
1906
|
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
1700
1907
|
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
|
|
1701
1908
|
// (server-computed on the current viewer). The service re-checks ownership;
|
|
1702
|
-
// the predicate keeps a non-submitter from ever seeing a button they
|
|
1703
|
-
// use.
|
|
1909
|
+
// the predicate keeps a plain non-submitter from ever seeing a button they
|
|
1910
|
+
// cannot use.
|
|
1911
|
+
//
|
|
1912
|
+
// `recall` is the one exception, and it is not a widening: it ALSO ORs in
|
|
1913
|
+
// the #3424 admin override (#12716), because an override admin is a caller
|
|
1914
|
+
// `ApprovalService.recall` already authorises. Remind and resubmit keep no
|
|
1915
|
+
// override arm.
|
|
1704
1916
|
{
|
|
1705
1917
|
name: "approval_remind",
|
|
1706
1918
|
label: "Send reminder",
|
|
@@ -1731,7 +1943,26 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1731
1943
|
],
|
|
1732
1944
|
// Recall applies while the request is live for the submitter — pending
|
|
1733
1945
|
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1734
|
-
|
|
1946
|
+
//
|
|
1947
|
+
// The second arm is the #3424 admin override, spelled byte-identically to
|
|
1948
|
+
// the three core decision levers above (#12716). `ApprovalService.recall`
|
|
1949
|
+
// has admitted the override caller since #3424 — `isOverrideActor`'s own
|
|
1950
|
+
// doc block names recall as one of the four levers — so until this arm
|
|
1951
|
+
// landed, recall was the one authorised capability with no button: an
|
|
1952
|
+
// admin could approve or reject their way out of a stuck request (writing
|
|
1953
|
+
// a decision that did not happen) or reassign it, but could not withdraw.
|
|
1954
|
+
//
|
|
1955
|
+
// The override arm carries no status test of its own, on purpose, because
|
|
1956
|
+
// it does not need one and the siblings do not have one either: the flag
|
|
1957
|
+
// is already status-scoped where it is COMPUTED. `attachViewers` in
|
|
1958
|
+
// `approval-service.ts` sets
|
|
1959
|
+
// `can_override: row.status === 'pending' && isOverrideActor(...)` —
|
|
1960
|
+
// ANDed — so `record.viewer.can_override` can never be true off `pending`,
|
|
1961
|
+
// and this arm is pending-only in effect however CEL groups the
|
|
1962
|
+
// expression. Pinned in both directions in
|
|
1963
|
+
// `action-predicate-sparse-face.test.ts`, with the flag's own scoping
|
|
1964
|
+
// pinned against the real service in `approval-revise.test.ts`.
|
|
1965
|
+
visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true',
|
|
1735
1966
|
locations: ["record_section"],
|
|
1736
1967
|
successMessage: "Recalled.",
|
|
1737
1968
|
refreshAfter: true
|
|
@@ -2092,6 +2323,7 @@ import {
|
|
|
2092
2323
|
normalizeDecisionOutputs
|
|
2093
2324
|
} from "@objectstack/spec/automation";
|
|
2094
2325
|
import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
|
|
2326
|
+
import { createRecordOrganizationResolver } from "@objectstack/metadata-core";
|
|
2095
2327
|
import { keysetWalk } from "@objectstack/types";
|
|
2096
2328
|
import {
|
|
2097
2329
|
ADMIN_FULL_ACCESS,
|
|
@@ -2102,6 +2334,7 @@ import {
|
|
|
2102
2334
|
} from "@objectstack/spec/identity";
|
|
2103
2335
|
import { RESUME_AUTHORITY_SERVICE } from "@objectstack/spec/contracts";
|
|
2104
2336
|
import { isFileIdToken } from "@objectstack/spec/data";
|
|
2337
|
+
import { renderOperationMessage } from "@objectstack/spec/system";
|
|
2105
2338
|
import { isGrantActive } from "@objectstack/core";
|
|
2106
2339
|
|
|
2107
2340
|
// src/approver-org-scope.ts
|
|
@@ -2220,11 +2453,44 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
|
2220
2453
|
return userIds.filter((u) => canRead.has(u));
|
|
2221
2454
|
}
|
|
2222
2455
|
|
|
2456
|
+
// src/payload-redaction.ts
|
|
2457
|
+
function redactSnapshot(payload, readable) {
|
|
2458
|
+
if (readable === void 0) return { payload, redactedKeys: [] };
|
|
2459
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2460
|
+
return { payload, redactedKeys: [] };
|
|
2461
|
+
}
|
|
2462
|
+
const allowed = new Set(readable.map((f) => String(f)));
|
|
2463
|
+
const source = payload;
|
|
2464
|
+
const redactedKeys = [];
|
|
2465
|
+
const kept = {};
|
|
2466
|
+
for (const key of Object.keys(source)) {
|
|
2467
|
+
if (allowed.has(key)) kept[key] = source[key];
|
|
2468
|
+
else redactedKeys.push(key);
|
|
2469
|
+
}
|
|
2470
|
+
if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
|
|
2471
|
+
return { payload: kept, redactedKeys: redactedKeys.sort() };
|
|
2472
|
+
}
|
|
2473
|
+
async function resolveReadableSnapshotFields(security, objectName, context, logger) {
|
|
2474
|
+
if (!security || typeof security.getReadableFields !== "function") return void 0;
|
|
2475
|
+
const object = String(objectName ?? "").trim();
|
|
2476
|
+
if (!object) return void 0;
|
|
2477
|
+
try {
|
|
2478
|
+
return await security.getReadableFields(object, context);
|
|
2479
|
+
} catch (err) {
|
|
2480
|
+
logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
|
|
2481
|
+
object,
|
|
2482
|
+
error: err?.message ?? String(err)
|
|
2483
|
+
});
|
|
2484
|
+
return void 0;
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2223
2488
|
// src/approval-service.ts
|
|
2224
2489
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
2225
2490
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
2226
2491
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
2227
2492
|
var SLA_ACTOR_ID = "system:sla";
|
|
2493
|
+
var ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse("2026-08-28T00:00:00Z");
|
|
2228
2494
|
var DEAD_RUN_ACTOR_ID = "system:dead-run";
|
|
2229
2495
|
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
2230
2496
|
"completed",
|
|
@@ -2233,13 +2499,43 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2233
2499
|
"timed_out"
|
|
2234
2500
|
]);
|
|
2235
2501
|
var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
|
|
2502
|
+
function classifyStrandedRunState(run) {
|
|
2503
|
+
if (!run) return "missing";
|
|
2504
|
+
switch (run.status) {
|
|
2505
|
+
// The resume consumed the pause and a downstream node threw. Reported.
|
|
2506
|
+
case "failed":
|
|
2507
|
+
return "failed";
|
|
2508
|
+
// ── The negatives, each for its own reason ──────────────────────────────
|
|
2509
|
+
// The decision advanced the flow and the flow finished. Healthy.
|
|
2510
|
+
case "completed":
|
|
2511
|
+
return void 0;
|
|
2512
|
+
// Deliberately terminated by an operator (`cancelRun`, ADR-0044). The run
|
|
2513
|
+
// stopping is the intended outcome, exactly as `recalled` is on the request
|
|
2514
|
+
// side — reporting it would bury the real findings under expected ones.
|
|
2515
|
+
case "cancelled":
|
|
2516
|
+
return void 0;
|
|
2517
|
+
// The history's last row says `paused` while the suspension store says no
|
|
2518
|
+
// live pause. That is AMBIGUOUS, and the ambiguity is not resolvable from
|
|
2519
|
+
// one scan: a resume in flight right now has consumed the suspension and
|
|
2520
|
+
// not yet written its terminal row, and reads exactly like a process that
|
|
2521
|
+
// died in the same window. Condemning it would name every concurrently
|
|
2522
|
+
// resuming approval — so this stays SKIPPED, the conservative arm this
|
|
2523
|
+
// whole method is built on.
|
|
2524
|
+
case "paused":
|
|
2525
|
+
return void 0;
|
|
2526
|
+
default:
|
|
2527
|
+
return void 0;
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2236
2530
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2237
2531
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2532
|
+
var RECORD_DELETE_CANCEL_LIMIT = 200;
|
|
2238
2533
|
function actingUserId(context) {
|
|
2239
2534
|
const userId = context?.userId;
|
|
2240
2535
|
return typeof userId === "string" && userId ? userId : null;
|
|
2241
2536
|
}
|
|
2242
2537
|
var OOO_MAX_CHAIN = 8;
|
|
2538
|
+
var MEMBER_SCREEN_READ_LIMIT = 5e4;
|
|
2243
2539
|
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2244
2540
|
"team",
|
|
2245
2541
|
"department",
|
|
@@ -2294,6 +2590,10 @@ function rowFromRequest(row) {
|
|
|
2294
2590
|
submitter_id: row.submitter_id ?? void 0,
|
|
2295
2591
|
submitter_comment: row.submitter_comment ?? void 0,
|
|
2296
2592
|
status: row.status ?? "pending",
|
|
2593
|
+
// [#13568] Why a cancelled row was cancelled. Surfaced on every read for
|
|
2594
|
+
// the same reason `status` is: a client that can see the terminal state and
|
|
2595
|
+
// not the machine-readable cause is back to guessing from prose.
|
|
2596
|
+
cancel_reason: row.cancel_reason ?? void 0,
|
|
2297
2597
|
current_step: row.current_step ?? void 0,
|
|
2298
2598
|
current_step_index: row.current_step_index ?? void 0,
|
|
2299
2599
|
pending_approvers: csvSplit(row.pending_approvers),
|
|
@@ -2386,12 +2686,15 @@ function rowFromAction(row) {
|
|
|
2386
2686
|
var _ApprovalService = class _ApprovalService {
|
|
2387
2687
|
constructor(opts) {
|
|
2388
2688
|
this.engine = opts.engine;
|
|
2689
|
+
this.recordOrgResolver = createRecordOrganizationResolver(opts.engine);
|
|
2389
2690
|
this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
|
|
2390
2691
|
this.logger = opts.logger;
|
|
2391
2692
|
this.automation = opts.automation;
|
|
2392
2693
|
this.messaging = opts.messaging;
|
|
2393
2694
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2394
2695
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2696
|
+
this.fieldVisibility = opts.fieldVisibility;
|
|
2697
|
+
this.messageTranslator = opts.messageTranslator;
|
|
2395
2698
|
this.recordReaderVisibleObjects = new Set(
|
|
2396
2699
|
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2397
2700
|
);
|
|
@@ -2400,6 +2703,57 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2400
2703
|
attachTenancyPosture(provider) {
|
|
2401
2704
|
this.tenancyPosture = provider;
|
|
2402
2705
|
}
|
|
2706
|
+
/**
|
|
2707
|
+
* [#10749] Attach (or replace) the field-visibility authority the payload
|
|
2708
|
+
* redaction seam reads. Late-bound: plugin load order does not guarantee the
|
|
2709
|
+
* security service exists when this one is constructed.
|
|
2710
|
+
*/
|
|
2711
|
+
attachFieldVisibility(source) {
|
|
2712
|
+
this.fieldVisibility = source;
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* [#10749] Redact each row's payload snapshot down to the fields the READING
|
|
2716
|
+
* caller may see on that row's subject object.
|
|
2717
|
+
*
|
|
2718
|
+
* Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
|
|
2719
|
+
* load-bearing rather than incidental: `enrichRows` derives `payload_display`
|
|
2720
|
+
* (lookup foreign keys inside the snapshot resolved to referenced record
|
|
2721
|
+
* titles) and `payload_labels` (a label per snapshot key) by WALKING THE
|
|
2722
|
+
* SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
|
|
2723
|
+
* redact after and a restricted field's name, its authored label and the
|
|
2724
|
+
* title of the record it points at all still ship — the value would be gone
|
|
2725
|
+
* and the disclosure would not.
|
|
2726
|
+
*
|
|
2727
|
+
* Rows are grouped by subject object so one `getReadableFields` call covers a
|
|
2728
|
+
* whole page of same-object requests.
|
|
2729
|
+
*/
|
|
2730
|
+
async redactPayloads(rows, context) {
|
|
2731
|
+
const withPayload = rows.filter((r) => r?.payload != null);
|
|
2732
|
+
if (withPayload.length === 0) return;
|
|
2733
|
+
const byObject = /* @__PURE__ */ new Map();
|
|
2734
|
+
for (const r of withPayload) {
|
|
2735
|
+
const key = String(r.object_name ?? "");
|
|
2736
|
+
let list = byObject.get(key);
|
|
2737
|
+
if (!list) {
|
|
2738
|
+
list = [];
|
|
2739
|
+
byObject.set(key, list);
|
|
2740
|
+
}
|
|
2741
|
+
list.push(r);
|
|
2742
|
+
}
|
|
2743
|
+
for (const [object, group] of byObject) {
|
|
2744
|
+
const readable = await resolveReadableSnapshotFields(
|
|
2745
|
+
this.fieldVisibility,
|
|
2746
|
+
object,
|
|
2747
|
+
context,
|
|
2748
|
+
this.logger
|
|
2749
|
+
);
|
|
2750
|
+
if (readable === void 0) continue;
|
|
2751
|
+
for (const r of group) {
|
|
2752
|
+
const { payload } = redactSnapshot(r.payload, readable);
|
|
2753
|
+
r.payload = payload;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2403
2757
|
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2404
2758
|
get orgScopeDeps() {
|
|
2405
2759
|
return {
|
|
@@ -2493,6 +2847,41 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2493
2847
|
const actorTenant = context.tenantId ?? context.organizationId ?? null;
|
|
2494
2848
|
return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
|
|
2495
2849
|
}
|
|
2850
|
+
/**
|
|
2851
|
+
* [#11993] The END USER's half of an approval refusal.
|
|
2852
|
+
*
|
|
2853
|
+
* `handleApprovalError` in `@objectstack/rest` maps this service's
|
|
2854
|
+
* `CODE: message` throws onto the wire by testing the prefix for the status
|
|
2855
|
+
* and then STRIPPING it (a leading run of `[A-Z_]` plus a colon and any
|
|
2856
|
+
* following whitespace), shipping what remains as the body's
|
|
2857
|
+
* human-readable `error` — which Console splices under its own localized
|
|
2858
|
+
* label ("撤回审批失败: …"). A hardcoded English reason therefore reaches an
|
|
2859
|
+
* operator in a fully Chinese deployment as a Chinese prefix glued onto an
|
|
2860
|
+
* English sentence they cannot act on.
|
|
2861
|
+
*
|
|
2862
|
+
* Rendered through the SHARED Operation Message Catalog
|
|
2863
|
+
* (`@objectstack/spec/system`), not a second mechanism: same
|
|
2864
|
+
* `errors.<key>` override address, same resolution ladder (deployment
|
|
2865
|
+
* override -> locale catalog -> `en` -> the key), same guarantee that a
|
|
2866
|
+
* misbehaving i18n service cannot turn a 403 into a 500. The catalog's
|
|
2867
|
+
* header names this seat explicitly; `plugin-security`'s
|
|
2868
|
+
* `userFacingDenialMessage` is the sibling consumer this mirrors.
|
|
2869
|
+
*
|
|
2870
|
+
* The CODE PREFIX stays on the message. It is not user copy — it is how the
|
|
2871
|
+
* REST layer derives the status and the ADR-0112 wire code, and it is
|
|
2872
|
+
* stripped before the sentence reaches a body. The developer's half moves
|
|
2873
|
+
* to the log, where the ids it names are legible to an operator and to
|
|
2874
|
+
* nobody else.
|
|
2875
|
+
*/
|
|
2876
|
+
userFacingRefusal(messageKey, context) {
|
|
2877
|
+
let translate;
|
|
2878
|
+
try {
|
|
2879
|
+
translate = this.messageTranslator?.();
|
|
2880
|
+
} catch {
|
|
2881
|
+
translate = void 0;
|
|
2882
|
+
}
|
|
2883
|
+
return renderOperationMessage({ messageKey }, { locale: context?.locale, translate });
|
|
2884
|
+
}
|
|
2496
2885
|
/**
|
|
2497
2886
|
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
2498
2887
|
*
|
|
@@ -2666,7 +3055,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2666
3055
|
}) : users;
|
|
2667
3056
|
try {
|
|
2668
3057
|
if (type === "team") {
|
|
2669
|
-
const users = await this.expandTeamUsers(String(a.value));
|
|
3058
|
+
const users = await this.expandTeamUsers(String(a.value), organizationId);
|
|
2670
3059
|
if (users.length) return users;
|
|
2671
3060
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
2672
3061
|
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
@@ -2680,7 +3069,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2680
3069
|
} else if (type === "manager" && record) {
|
|
2681
3070
|
const subject = record[a.value] ?? record.owner_id;
|
|
2682
3071
|
if (subject) {
|
|
2683
|
-
const mgr = await this.lookupManager(String(subject));
|
|
3072
|
+
const mgr = await this.lookupManager(String(subject), organizationId);
|
|
2684
3073
|
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
2685
3074
|
}
|
|
2686
3075
|
}
|
|
@@ -2777,7 +3166,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2777
3166
|
try {
|
|
2778
3167
|
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2779
3168
|
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2780
|
-
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
3169
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
|
|
2781
3170
|
else {
|
|
2782
3171
|
throw new Error(
|
|
2783
3172
|
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
@@ -2802,9 +3191,50 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2802
3191
|
}
|
|
2803
3192
|
return { slots, raw };
|
|
2804
3193
|
}
|
|
2805
|
-
/**
|
|
2806
|
-
|
|
3194
|
+
/**
|
|
3195
|
+
* Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
|
|
3196
|
+
*
|
|
3197
|
+
* Takes an organization for the reason every sibling expansion does
|
|
3198
|
+
* ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
|
|
3199
|
+
* {@link expandMembershipTierUsers}): an approver expansion answers "who, in
|
|
3200
|
+
* THIS organization". Before #10230 this one did not ask, and it was the last
|
|
3201
|
+
* expansion that did not — a `team` approver naming ANOTHER organization's
|
|
3202
|
+
* team routed that organization's people an approval over a record they are
|
|
3203
|
+
* not a tenant of.
|
|
3204
|
+
*
|
|
3205
|
+
* TWO screens run here, and they assert different things (#10230, #10547):
|
|
3206
|
+
*
|
|
3207
|
+
* 1. the TEAM must not provably belong to another organization
|
|
3208
|
+
* ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
|
|
3209
|
+
* `organization_id` outright
|
|
3210
|
+
* (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
|
|
3211
|
+
* team id transitively names exactly one organization and ONE row
|
|
3212
|
+
* answers the question;
|
|
3213
|
+
* 2. each expanded MEMBER must not provably hold membership only in other
|
|
3214
|
+
* organizations ({@link dropMembersProvablyOutsideOrg}) —
|
|
3215
|
+
* `sys_team_member` carries `team_id` and `user_id` and NO tenancy
|
|
3216
|
+
* column at all, so passing (1) says nothing whatever about the people
|
|
3217
|
+
* it lists.
|
|
3218
|
+
*
|
|
3219
|
+
* #10230 landed (1) alone and deferred (2) on purpose. What closed the
|
|
3220
|
+
* deferral is that (1) does not imply (2) even a little: a member removed
|
|
3221
|
+
* from the organization but left on the team, a team re-parented across
|
|
3222
|
+
* organizations (`/organization/update-team` accepts `organizationId` in its
|
|
3223
|
+
* partial body), or a `sys_team_member` row written by a seed rather than
|
|
3224
|
+
* through better-auth all produce a team that passes (1) carrying a user who
|
|
3225
|
+
* is provably a tenant of somewhere else. Measured on this tree, not read off
|
|
3226
|
+
* the schema — the probe is quoted in `team-member-org-screen.test.ts`.
|
|
3227
|
+
*
|
|
3228
|
+
* (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
|
|
3229
|
+
* further out, and it is asserted the same way: `sys_user` carries no tenancy
|
|
3230
|
+
* fact, so `sys_member` rows are the only evidence that a person is placed
|
|
3231
|
+
* anywhere. Like that screen, this one grants no reads and applies no read
|
|
3232
|
+
* screen to any approver type that lacks one today, so it decides nothing
|
|
3233
|
+
* #7497 (does approver routing imply record read visibility?) asks.
|
|
3234
|
+
*/
|
|
3235
|
+
async expandTeamUsers(teamId, organizationId) {
|
|
2807
3236
|
if (!teamId) return [];
|
|
3237
|
+
if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
|
|
2808
3238
|
let rows = [];
|
|
2809
3239
|
try {
|
|
2810
3240
|
rows = await this.engine.find("sys_team_member", {
|
|
@@ -2816,7 +3246,149 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2816
3246
|
} catch {
|
|
2817
3247
|
rows = [];
|
|
2818
3248
|
}
|
|
2819
|
-
|
|
3249
|
+
const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
3250
|
+
return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
|
|
3251
|
+
}
|
|
3252
|
+
/**
|
|
3253
|
+
* Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
|
|
3254
|
+
*
|
|
3255
|
+
* "Provably" carries the same posture the sibling screen states at length in
|
|
3256
|
+
* {@link managerIsProvablyOutsideOrg}, for the same reasons:
|
|
3257
|
+
*
|
|
3258
|
+
* - the team row carries an `organization_id` and it is not the request's
|
|
3259
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
|
|
3260
|
+
* - the row carries no `organization_id`, does not exist, or the read failed
|
|
3261
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3262
|
+
*
|
|
3263
|
+
* The `organization_id = null` limb is not timidity — it is the reading
|
|
3264
|
+
* {@link businessUnitOrgScope} settled on one screen below, for the identical
|
|
3265
|
+
* shape: null on a platform object means "owned by no organization", which is
|
|
3266
|
+
* what a seed writes because a seed cannot know the organization id the
|
|
3267
|
+
* runtime mints at boot. Treating null as "not mine" would delete every
|
|
3268
|
+
* seeded team approver at once — a larger behaviour change than the hole
|
|
3269
|
+
* being closed. Measured, and not hypothetically: this package's own
|
|
3270
|
+
* `team_ok` expansion fixture is exactly such a stack (it has
|
|
3271
|
+
* `sys_team_member` rows, a request carrying an organization, and no
|
|
3272
|
+
* `sys_team` row at all).
|
|
3273
|
+
*
|
|
3274
|
+
* Screening the TEAM before reading its members is also what keeps the cost
|
|
3275
|
+
* at one row: a team that fails the screen never fans out.
|
|
3276
|
+
*/
|
|
3277
|
+
async teamIsProvablyOutsideOrg(teamId, organizationId) {
|
|
3278
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3279
|
+
if (!requestOrg) return false;
|
|
3280
|
+
let rows = [];
|
|
3281
|
+
try {
|
|
3282
|
+
rows = await this.engine.find("sys_team", {
|
|
3283
|
+
where: { id: teamId },
|
|
3284
|
+
fields: ["id", "organization_id"],
|
|
3285
|
+
limit: 1,
|
|
3286
|
+
context: SYSTEM_CTX2
|
|
3287
|
+
});
|
|
3288
|
+
} catch {
|
|
3289
|
+
return false;
|
|
3290
|
+
}
|
|
3291
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
3292
|
+
const teamOrg = row?.organization_id ? String(row.organization_id) : "";
|
|
3293
|
+
if (!teamOrg) return false;
|
|
3294
|
+
if (teamOrg === requestOrg) return false;
|
|
3295
|
+
this.logger?.warn?.(
|
|
3296
|
+
`[approvals] #10230: team '${teamId}' was dropped from the approver slate \u2014 'sys_team.organization_id' is '${teamOrg}', not the request's organization '${requestOrg}', so routing this approval to its members would put approval authority over the record outside its tenant. Point the approver at a team in this organization, or route this step with an approver type that names someone in it.`,
|
|
3297
|
+
{ teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
|
|
3298
|
+
);
|
|
3299
|
+
return true;
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* Drop the expanded team members who are PROVABLY tenants of other
|
|
3303
|
+
* organizations and not of `organizationId`. (#10547)
|
|
3304
|
+
*
|
|
3305
|
+
* Returns the survivors, in the order they were expanded.
|
|
3306
|
+
*
|
|
3307
|
+
* Posture — identical to {@link managerIsProvablyOutsideOrg} and
|
|
3308
|
+
* {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
|
|
3309
|
+
* assertion about the same table:
|
|
3310
|
+
*
|
|
3311
|
+
* - membership rows exist for this user, none in `organizationId`
|
|
3312
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
|
|
3313
|
+
* - no membership rows at all for him, the read failed, or the request
|
|
3314
|
+
* carries no organization
|
|
3315
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3316
|
+
*
|
|
3317
|
+
* The absent limb is load-bearing rather than timid, and #3807 is the recorded
|
|
3318
|
+
* cost of getting it wrong: a stack that stamps an organization on requests
|
|
3319
|
+
* but never materializes `sys_member` rows would otherwise lose EVERY team
|
|
3320
|
+
* approver at once. This package's own `team_ok` expansion fixture and
|
|
3321
|
+
* #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
|
|
3322
|
+
* a request organization and no `sys_member` table at all — so the absent
|
|
3323
|
+
* limb is exercised by neighbours on every run of this suite.
|
|
3324
|
+
*
|
|
3325
|
+
* ONE read for the whole slate, never one per person: the expansion is capped
|
|
3326
|
+
* at 10000 members and a per-user query would turn a single team approver
|
|
3327
|
+
* into 10000 round trips.
|
|
3328
|
+
*
|
|
3329
|
+
* ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
|
|
3330
|
+
* the only evidence that a member IS a tenant here, so a result cut off at
|
|
3331
|
+
* the limit could be missing the very row that keeps a legitimate approver on
|
|
3332
|
+
* the slate — screening him out on missing evidence, which inverts the
|
|
3333
|
+
* posture into fail-CLOSED precisely where it must not. When the read comes
|
|
3334
|
+
* back at the cap it is treated as no evidence at all.
|
|
3335
|
+
*/
|
|
3336
|
+
async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
|
|
3337
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3338
|
+
if (!requestOrg || !userIds.length) return userIds;
|
|
3339
|
+
let rows = [];
|
|
3340
|
+
try {
|
|
3341
|
+
rows = await this.engine.find("sys_member", {
|
|
3342
|
+
where: { user_id: { $in: userIds } },
|
|
3343
|
+
fields: ["user_id", "organization_id"],
|
|
3344
|
+
limit: MEMBER_SCREEN_READ_LIMIT,
|
|
3345
|
+
context: SYSTEM_CTX2
|
|
3346
|
+
});
|
|
3347
|
+
} catch {
|
|
3348
|
+
return userIds;
|
|
3349
|
+
}
|
|
3350
|
+
if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
|
|
3351
|
+
this.logger?.warn?.(
|
|
3352
|
+
`[approvals] #10547: the membership screen for team '${teamId}' read ${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row cap, so the result may be truncated. Routing is left unchanged rather than risk dropping a member whose proof of membership fell outside the read.`,
|
|
3353
|
+
{ teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
|
|
3354
|
+
);
|
|
3355
|
+
return userIds;
|
|
3356
|
+
}
|
|
3357
|
+
const orgsByUser = /* @__PURE__ */ new Map();
|
|
3358
|
+
for (const r of rows ?? []) {
|
|
3359
|
+
const uid2 = String(r?.user_id ?? "");
|
|
3360
|
+
const org = String(r?.organization_id ?? "");
|
|
3361
|
+
if (!uid2 || !org) continue;
|
|
3362
|
+
const seen = orgsByUser.get(uid2);
|
|
3363
|
+
if (seen) seen.push(org);
|
|
3364
|
+
else orgsByUser.set(uid2, [org]);
|
|
3365
|
+
}
|
|
3366
|
+
const kept = [];
|
|
3367
|
+
const dropped = [];
|
|
3368
|
+
for (const uid2 of userIds) {
|
|
3369
|
+
const orgs = orgsByUser.get(uid2);
|
|
3370
|
+
if (!orgs?.length) {
|
|
3371
|
+
kept.push(uid2);
|
|
3372
|
+
continue;
|
|
3373
|
+
}
|
|
3374
|
+
if (orgs.includes(requestOrg)) {
|
|
3375
|
+
kept.push(uid2);
|
|
3376
|
+
continue;
|
|
3377
|
+
}
|
|
3378
|
+
dropped.push({ userId: uid2, organizationIds: orgs });
|
|
3379
|
+
}
|
|
3380
|
+
if (dropped.length) {
|
|
3381
|
+
this.logger?.warn?.(
|
|
3382
|
+
`[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from the approver slate \u2014 ${dropped.map((d) => `'${d.userId}'`).join(", ")} hold membership in other organization(s), none of them the request's organization '${requestOrg}', so routing this approval to them would put approval authority over the record outside its tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry no organization of their own. Remove them from the team, grant them a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3383
|
+
{
|
|
3384
|
+
teamId,
|
|
3385
|
+
requestOrganizationId: requestOrg,
|
|
3386
|
+
droppedUserIds: dropped.map((d) => d.userId),
|
|
3387
|
+
droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
|
|
3388
|
+
}
|
|
3389
|
+
);
|
|
3390
|
+
}
|
|
3391
|
+
return kept;
|
|
2820
3392
|
}
|
|
2821
3393
|
/**
|
|
2822
3394
|
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
@@ -2985,7 +3557,36 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2985
3557
|
}
|
|
2986
3558
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
2987
3559
|
}
|
|
2988
|
-
|
|
3560
|
+
/**
|
|
3561
|
+
* `sys_user.manager_id`, screened to the request's organization (#10153).
|
|
3562
|
+
*
|
|
3563
|
+
* Takes an organization argument for the same reason its siblings do
|
|
3564
|
+
* ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
|
|
3565
|
+
* approver expansion answers "who, in THIS organization". Before #10153 this
|
|
3566
|
+
* one did not ask, and it was the only expansion that did not — a
|
|
3567
|
+
* `manager_id` pointing at a person in another organization routed that
|
|
3568
|
+
* person an approval over a record they are not a tenant of.
|
|
3569
|
+
*
|
|
3570
|
+
* ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
|
|
3571
|
+
* filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
|
|
3572
|
+
* comment exists so the next reader does not conclude that #7497 (does
|
|
3573
|
+
* approver routing imply record read visibility?) was settled here. It was
|
|
3574
|
+
* not. Two facts make this the SIBLING treatment rather than a
|
|
3575
|
+
* read-visibility ruling:
|
|
3576
|
+
*
|
|
3577
|
+
* 1. Two of the three org-scoped expansions already screen on exactly this
|
|
3578
|
+
* column — `expandMembershipTierUsers` filters `sys_member.organization_id`
|
|
3579
|
+
* outright, and it is also the second limb of `expandPositionUsers`. So
|
|
3580
|
+
* `sys_member.organization_id` is already this file's answer to "which
|
|
3581
|
+
* organization is this person in", independent of what they may read.
|
|
3582
|
+
* 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
|
|
3583
|
+
* table, so a membership row is the only tenancy fact that exists for a
|
|
3584
|
+
* user — there is no other read this screen could have been written with.
|
|
3585
|
+
*
|
|
3586
|
+
* This change grants no reads and applies no read screen to any type that
|
|
3587
|
+
* lacks one today, so it decides nothing #7497 asks.
|
|
3588
|
+
*/
|
|
3589
|
+
async lookupManager(userId, organizationId) {
|
|
2989
3590
|
try {
|
|
2990
3591
|
const rows = await this.engine.find("sys_user", {
|
|
2991
3592
|
where: { id: userId },
|
|
@@ -2994,11 +3595,64 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2994
3595
|
context: SYSTEM_CTX2
|
|
2995
3596
|
});
|
|
2996
3597
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2997
|
-
|
|
3598
|
+
const managerId = row?.manager_id ? String(row.manager_id) : null;
|
|
3599
|
+
if (!managerId) return null;
|
|
3600
|
+
if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
|
|
3601
|
+
return managerId;
|
|
2998
3602
|
} catch {
|
|
2999
3603
|
return null;
|
|
3000
3604
|
}
|
|
3001
3605
|
}
|
|
3606
|
+
/**
|
|
3607
|
+
* Is `managerId` PROVABLY a member of other organizations and not of
|
|
3608
|
+
* `organizationId`? (#10153)
|
|
3609
|
+
*
|
|
3610
|
+
* "Provably" is the whole shape of this screen, and it is deliberate rather
|
|
3611
|
+
* than a weaker version of "must prove membership":
|
|
3612
|
+
*
|
|
3613
|
+
* - membership rows exist for this user, none in the request's org
|
|
3614
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
|
|
3615
|
+
* - no membership rows at all, or the read failed
|
|
3616
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3617
|
+
*
|
|
3618
|
+
* The fail-open half is not timidity, it is this file's ruled posture on
|
|
3619
|
+
* addressing paths, stated twice already: {@link filterApproversWhoCanRead}
|
|
3620
|
+
* refuses to empty a live slate on an infrastructure hiccup, and
|
|
3621
|
+
* {@link expandPositionUsers} carries "a step routing to nobody is worse than
|
|
3622
|
+
* one routing to a lapsed holder". It is also load-bearing in practice — a
|
|
3623
|
+
* stack that stamps an organization on its requests but does not materialize
|
|
3624
|
+
* `sys_member` rows would otherwise lose every manager approver at once,
|
|
3625
|
+
* which is a bigger behaviour change than the hole being closed. Measured:
|
|
3626
|
+
* this repo's own `type:manager` out-of-office fixture is such a stack.
|
|
3627
|
+
*
|
|
3628
|
+
* Screening the MANAGER only, before OOO delegation, is deliberate too: the
|
|
3629
|
+
* delegate arrives from `sys_approval_delegation`, whose rows already carry
|
|
3630
|
+
* (and are already filtered by) an `organization_id` in
|
|
3631
|
+
* {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
|
|
3632
|
+
*/
|
|
3633
|
+
async managerIsProvablyOutsideOrg(managerId, organizationId) {
|
|
3634
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3635
|
+
if (!requestOrg) return false;
|
|
3636
|
+
let rows = [];
|
|
3637
|
+
try {
|
|
3638
|
+
rows = await this.engine.find("sys_member", {
|
|
3639
|
+
where: { user_id: managerId },
|
|
3640
|
+
fields: ["user_id", "organization_id"],
|
|
3641
|
+
limit: 1e3,
|
|
3642
|
+
context: SYSTEM_CTX2
|
|
3643
|
+
});
|
|
3644
|
+
} catch {
|
|
3645
|
+
return false;
|
|
3646
|
+
}
|
|
3647
|
+
const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
|
|
3648
|
+
if (!orgs.length) return false;
|
|
3649
|
+
if (orgs.includes(requestOrg)) return false;
|
|
3650
|
+
this.logger?.warn?.(
|
|
3651
|
+
`[approvals] #10153: manager '${managerId}' was dropped from the approver slate \u2014 'sys_user.manager_id' points across an organization boundary. He holds membership in ${orgs.length} organization(s), none of them the request's organization '${requestOrg}', so routing this approval to him would put approval authority over the record outside its tenant. Fix the 'manager_id' link, grant him a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3652
|
+
{ managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
|
|
3653
|
+
);
|
|
3654
|
+
return true;
|
|
3655
|
+
}
|
|
3002
3656
|
/**
|
|
3003
3657
|
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
3004
3658
|
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
@@ -3168,11 +3822,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3168
3822
|
const substitutions = [];
|
|
3169
3823
|
const groups = {};
|
|
3170
3824
|
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
3825
|
+
const subjectOrg = this.recordOrgResolver.organizationOf(input.object, liveRecord, input.record);
|
|
3826
|
+
const requestOrg = subjectOrg ?? ctxOrg;
|
|
3171
3827
|
const resolvedFrom = {};
|
|
3172
3828
|
const approvers = await this.expandApprovers(
|
|
3173
3829
|
{ approvers: input.config.approvers },
|
|
3174
3830
|
liveRecord,
|
|
3175
|
-
|
|
3831
|
+
requestOrg,
|
|
3176
3832
|
{
|
|
3177
3833
|
now: nowDate.getTime(),
|
|
3178
3834
|
substitutions,
|
|
@@ -3236,16 +3892,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3236
3892
|
flow_run_id: input.runId,
|
|
3237
3893
|
flow_node_id: input.nodeId,
|
|
3238
3894
|
node_config_json: JSON.stringify(configSnapshot),
|
|
3239
|
-
organization_id:
|
|
3895
|
+
organization_id: requestOrg,
|
|
3240
3896
|
created_at: now,
|
|
3241
3897
|
updated_at: now
|
|
3242
3898
|
};
|
|
3243
3899
|
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
3244
|
-
await this.syncApproverIndex(id, approvers,
|
|
3900
|
+
await this.syncApproverIndex(id, approvers, requestOrg, now);
|
|
3245
3901
|
await this.engine.insert("sys_approval_action", {
|
|
3246
3902
|
id: uid("aact"),
|
|
3247
3903
|
request_id: id,
|
|
3248
|
-
organization_id:
|
|
3904
|
+
organization_id: requestOrg,
|
|
3249
3905
|
step_name: input.nodeId,
|
|
3250
3906
|
step_index: 0,
|
|
3251
3907
|
action: "submit",
|
|
@@ -3257,7 +3913,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3257
3913
|
await this.engine.insert("sys_approval_action", {
|
|
3258
3914
|
id: uid("aact"),
|
|
3259
3915
|
request_id: id,
|
|
3260
|
-
organization_id:
|
|
3916
|
+
organization_id: requestOrg,
|
|
3261
3917
|
step_name: input.nodeId,
|
|
3262
3918
|
step_index: 0,
|
|
3263
3919
|
action: "ooo_substitute",
|
|
@@ -3699,6 +4355,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3699
4355
|
* is then paused at the revise-window node (no reject edge), so it is
|
|
3700
4356
|
* terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
|
|
3701
4357
|
* than resumed.
|
|
4358
|
+
*
|
|
4359
|
+
* The #3424 privileged override reaches a PENDING request only (#12775,
|
|
4360
|
+
* maintainer ruling 2026-09-02). On `returned` an override actor is refused
|
|
4361
|
+
* exactly as any other non-submitter: the gate is spelled as `attachViewers`
|
|
4362
|
+
* computes `viewer.can_override`, so the gate, the
|
|
4363
|
+
* {@link ApprovalService.isOverrideActor} doc block and the viewer flag agree
|
|
4364
|
+
* at one point.
|
|
3702
4365
|
*/
|
|
3703
4366
|
async recall(requestId, input, context) {
|
|
3704
4367
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
@@ -3714,8 +4377,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3714
4377
|
if (raw.status !== "pending" && !inReviseWindow) {
|
|
3715
4378
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
3716
4379
|
}
|
|
3717
|
-
|
|
3718
|
-
|
|
4380
|
+
const overrideAdmits = raw.status === "pending" && this.isOverrideActor(context, raw.organization_id ?? null);
|
|
4381
|
+
if (!overrideAdmits && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
4382
|
+
const developerMessage = `[approvals] recall refused: actor '${actorId}' is not the submitter of request '${requestId}' (submitter '${String(raw.submitter_id)}') and holds no #3424 override for a '${raw.status}' request (the override reaches pending requests only)`;
|
|
4383
|
+
this.logger?.warn?.(developerMessage, {
|
|
4384
|
+
request: requestId,
|
|
4385
|
+
actor: actorId,
|
|
4386
|
+
submitter: String(raw.submitter_id),
|
|
4387
|
+
status: raw.status
|
|
4388
|
+
});
|
|
4389
|
+
throw new Error(
|
|
4390
|
+
`FORBIDDEN: ${this.userFacingRefusal("approval_recall_not_submitter", context)}`
|
|
4391
|
+
);
|
|
3719
4392
|
}
|
|
3720
4393
|
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
3721
4394
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
@@ -3791,6 +4464,127 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3791
4464
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3792
4465
|
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3793
4466
|
}
|
|
4467
|
+
// ── Record-delete lifecycle linkage (#13568) ─────────────────
|
|
4468
|
+
/**
|
|
4469
|
+
* Void every `pending` request about a record that has just been DELETED
|
|
4470
|
+
* (#13568, maintainer ruling 2026-08-31 「同意」).
|
|
4471
|
+
*
|
|
4472
|
+
* ## What the ruling asked for, and what each half costs
|
|
4473
|
+
*
|
|
4474
|
+
* status → `cancelled`, a machine-readable `cancel_reason`, the row KEPT
|
|
4475
|
+
* for audit, and the request out of the pending count and the inbox's
|
|
4476
|
+
* default view.
|
|
4477
|
+
*
|
|
4478
|
+
* The last clause needs both writes below, not just the status one: the
|
|
4479
|
+
* inbox's "My Pending" tab pages the request table by `status`, but the
|
|
4480
|
+
* approver filter resolves through the normalized `sys_approval_approver`
|
|
4481
|
+
* index (#1745), which is keyed on nothing but `request_id`. Leaving the
|
|
4482
|
+
* index rows behind would keep the request in `approverRequestIds`' answer
|
|
4483
|
+
* for every approver forever — so {@link syncApproverIndex} clears them, the
|
|
4484
|
+
* same call every other exit from `pending` makes.
|
|
4485
|
+
*
|
|
4486
|
+
* ## Three things this deliberately does NOT do
|
|
4487
|
+
*
|
|
4488
|
+
* 1. **No status mirror-back.** Every other terminal transition calls
|
|
4489
|
+
* {@link mirrorStatusField} to write the outcome onto the subject record's
|
|
4490
|
+
* `approvalStatusField`. Here the subject record is precisely what no
|
|
4491
|
+
* longer exists, so that write is guaranteed to fail — it is the very
|
|
4492
|
+
* `update_record(...) failed: Record ... not found` this card's own
|
|
4493
|
+
* forensics recorded on the reject door. Skipped by construction, not by a
|
|
4494
|
+
* swallowed error.
|
|
4495
|
+
*
|
|
4496
|
+
* 2. **No flow resume, and no run cancel.** A cancellation is a status write
|
|
4497
|
+
* plus a reason; it is not a decision, so there is no branch to resume
|
|
4498
|
+
* down and no downstream node that should run. The suspended run this
|
|
4499
|
+
* request gated is reported (below) and left alone — what becomes of an
|
|
4500
|
+
* approval run whose request was voided is a lifecycle question that
|
|
4501
|
+
* belongs to the automation service, not to a delete hook, and answering
|
|
4502
|
+
* it here by guessing would be exactly the kind of consumer-side
|
|
4503
|
+
* accommodation PD #12 refuses.
|
|
4504
|
+
*
|
|
4505
|
+
* 3. **No refusal of the delete.** The delete has already landed by the time
|
|
4506
|
+
* this runs, and the "forbid delete while an approval is pending"
|
|
4507
|
+
* direction was VETOED in the same ruling (`lockRecord` already blocks the
|
|
4508
|
+
* edit; blocking the delete too locks an author onto a record they cannot
|
|
4509
|
+
* fix). So nothing here throws — a failure degrades to a warning and the
|
|
4510
|
+
* stale row, which is the pre-existing state, never to a failed delete.
|
|
4511
|
+
*
|
|
4512
|
+
* Terminal rows are untouched: the `where` below names `status: 'pending'`,
|
|
4513
|
+
* so an `approved` / `rejected` / `recalled` / `returned` request about the
|
|
4514
|
+
* same record keeps its recorded outcome. That is the ruling's second half —
|
|
4515
|
+
* history is kept; only the presentation of its dead record reference is
|
|
4516
|
+
* someone else's card.
|
|
4517
|
+
*
|
|
4518
|
+
* @returns the request ids cancelled and the suspended run ids left behind.
|
|
4519
|
+
*/
|
|
4520
|
+
async cancelForDeletedRecord(objectName, recordId) {
|
|
4521
|
+
const empty = { cancelled: [], suspendedRuns: [] };
|
|
4522
|
+
const object = String(objectName ?? "").trim();
|
|
4523
|
+
const record = String(recordId ?? "").trim();
|
|
4524
|
+
if (!object || !record) return empty;
|
|
4525
|
+
let pending;
|
|
4526
|
+
try {
|
|
4527
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
4528
|
+
where: { object_name: object, record_id: record, status: "pending" },
|
|
4529
|
+
limit: RECORD_DELETE_CANCEL_LIMIT,
|
|
4530
|
+
context: SYSTEM_CTX2
|
|
4531
|
+
});
|
|
4532
|
+
pending = Array.isArray(rows) ? rows : [];
|
|
4533
|
+
} catch (err) {
|
|
4534
|
+
this.logger?.warn?.(
|
|
4535
|
+
"[approvals] could not read the pending requests of a deleted record \u2014 they stay in the inbox until the record is deleted again or an operator finalises them",
|
|
4536
|
+
{ object, record, error: err?.message ?? String(err) }
|
|
4537
|
+
);
|
|
4538
|
+
return empty;
|
|
4539
|
+
}
|
|
4540
|
+
if (pending.length === 0) return empty;
|
|
4541
|
+
const now = this.clock.now().toISOString();
|
|
4542
|
+
const cancelled = [];
|
|
4543
|
+
const suspendedRuns = [];
|
|
4544
|
+
for (const raw of pending) {
|
|
4545
|
+
const requestId = String(raw?.id ?? "");
|
|
4546
|
+
if (!requestId) continue;
|
|
4547
|
+
const org = raw?.organization_id ?? null;
|
|
4548
|
+
const nodeId = raw?.flow_node_id ?? raw?.current_step ?? null;
|
|
4549
|
+
try {
|
|
4550
|
+
await this.engine.insert("sys_approval_action", {
|
|
4551
|
+
id: uid("aact"),
|
|
4552
|
+
request_id: requestId,
|
|
4553
|
+
organization_id: org,
|
|
4554
|
+
step_name: nodeId,
|
|
4555
|
+
step_index: 0,
|
|
4556
|
+
action: "cancel",
|
|
4557
|
+
actor_id: null,
|
|
4558
|
+
comment: `Auto-cancelled: the ${object} record '${record}' this request is about was deleted`,
|
|
4559
|
+
created_at: now
|
|
4560
|
+
}, { context: SYSTEM_CTX2 });
|
|
4561
|
+
await this.engine.update("sys_approval_request", {
|
|
4562
|
+
id: requestId,
|
|
4563
|
+
status: "cancelled",
|
|
4564
|
+
cancel_reason: "record_deleted",
|
|
4565
|
+
pending_approvers: null,
|
|
4566
|
+
completed_at: now,
|
|
4567
|
+
updated_at: now
|
|
4568
|
+
}, { context: SYSTEM_CTX2 });
|
|
4569
|
+
await this.syncApproverIndex(requestId, [], org, now);
|
|
4570
|
+
cancelled.push(requestId);
|
|
4571
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4572
|
+
if (runId) suspendedRuns.push(runId);
|
|
4573
|
+
} catch (err) {
|
|
4574
|
+
this.logger?.warn?.(
|
|
4575
|
+
"[approvals] could not auto-cancel a pending request whose record was deleted \u2014 it stays in the inbox pointing at a record that no longer exists",
|
|
4576
|
+
{ object, record, request: requestId, error: err?.message ?? String(err) }
|
|
4577
|
+
);
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
if (suspendedRuns.length) {
|
|
4581
|
+
this.logger?.warn?.(
|
|
4582
|
+
"[approvals] auto-cancelled approval request(s) for a deleted record \u2014 the automation run(s) they gated stay suspended and are not resumed by a cancellation",
|
|
4583
|
+
{ object, record, requests: cancelled, runs: suspendedRuns }
|
|
4584
|
+
);
|
|
4585
|
+
}
|
|
4586
|
+
return { cancelled, suspendedRuns };
|
|
4587
|
+
}
|
|
3794
4588
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
3795
4589
|
/**
|
|
3796
4590
|
* ADR-0044 send back for revision. Finalises the pending request as
|
|
@@ -4421,7 +5215,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4421
5215
|
* `escalation.timeoutHours` and whose deadline has passed is escalated
|
|
4422
5216
|
* **at most once, ever** — the `escalate` audit row is the idempotency
|
|
4423
5217
|
* marker, written before any mutation (audit-first, like reassign). One
|
|
4424
|
-
* bad row never stops the sweep.
|
|
5218
|
+
* bad row never stops the sweep. A block that explicitly declares
|
|
5219
|
+
* `enabled: false` is skipped (#12278 — the declared switch is enforced;
|
|
5220
|
+
* pre-flip snapshots ride the legacy window on
|
|
5221
|
+
* {@link ESCALATION_ENABLED_FLIP_CUTOFF_MS}).
|
|
4425
5222
|
*/
|
|
4426
5223
|
async runEscalations() {
|
|
4427
5224
|
let rows = [];
|
|
@@ -4443,6 +5240,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4443
5240
|
const cfg = parseJson(raw.node_config_json, void 0);
|
|
4444
5241
|
const esc2 = cfg?.escalation;
|
|
4445
5242
|
if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
|
|
5243
|
+
if (esc2.enabled === false && Date.parse(raw.created_at ?? "") >= ESCALATION_ENABLED_FLIP_CUTOFF_MS) continue;
|
|
4446
5244
|
const due = slaDueAt(raw.created_at, cfg);
|
|
4447
5245
|
if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
|
|
4448
5246
|
const prior = await this.engine.find("sys_approval_action", {
|
|
@@ -4523,9 +5321,35 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4523
5321
|
* live pause exists. It THROWS when the store cannot be read, and that
|
|
4524
5322
|
* case is SKIPPED, never counted as dead: an unreadable store means
|
|
4525
5323
|
* "unknown", and a storage outage must not be published as a lost run.
|
|
4526
|
-
* - `getRun(runId)
|
|
4527
|
-
* prefixed rows in `sys_automation_run`). A run
|
|
4528
|
-
* not stranded; a request whose run neither waits
|
|
5324
|
+
* - `classifyStrandedRunState` over `getRun(runId)` — the run's own
|
|
5325
|
+
* history row (the `run_` prefixed rows in `sys_automation_run`). A run
|
|
5326
|
+
* that merely finished is not stranded; a request whose run neither waits
|
|
5327
|
+
* nor completed is.
|
|
5328
|
+
*
|
|
5329
|
+
* **The second oracle was widened (#13909), and this is the whole point of
|
|
5330
|
+
* that card's first slice.** It used to be `if (terminal) continue` — the
|
|
5331
|
+
* existence of ANY history row ended the check, on the reading "the run ran to
|
|
5332
|
+
* a terminal state, it is not dangling". That is true of a run that COMPLETED
|
|
5333
|
+
* and false of one that FAILED: the engine consumes a suspension *before*
|
|
5334
|
+
* running the downstream nodes (`AutomationEngine.resumeInternal` calls
|
|
5335
|
+
* `forgetSuspendedRun(run, 'resumed')` and only then `traverseNext`), so a
|
|
5336
|
+
* downstream node that merely threw threw with the pause already gone, and the
|
|
5337
|
+
* catch arm wrote a terminal `failed` row. The decision is durable, the
|
|
5338
|
+
* continuation stopped half-way, `resume` answers `RUN_NOT_FOUND` and
|
|
5339
|
+
* `cancelRun` is a no-op — and the terminal row this oracle used to read as
|
|
5340
|
+
* health is written BY the very failure that stranded it. So this inspection
|
|
5341
|
+
* reported `0` for the one shape an operator most needs to see.
|
|
5342
|
+
*
|
|
5343
|
+
* ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
|
|
5344
|
+
* and `paused` are each still skipped, for reasons named one at a time in
|
|
5345
|
+
* `classifyStrandedRunState`, and an unrecognised status is skipped too.
|
|
5346
|
+
* What the widening buys is that a `failed` run is now reported with
|
|
5347
|
+
* `runState: 'failed'` instead of counted as healthy.
|
|
5348
|
+
*
|
|
5349
|
+
* ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
|
|
5350
|
+
* deployment; it is not itself a census, and it says nothing about this
|
|
5351
|
+
* repository. How many runs are already in this state can only be answered
|
|
5352
|
+
* against a real deployment's tables — see the card.
|
|
4529
5353
|
*
|
|
4530
5354
|
* **Reports; never rewrites.** No status is changed and no run is cancelled.
|
|
4531
5355
|
* The decision genuinely happened — a human approved or rejected — and
|
|
@@ -4583,7 +5407,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4583
5407
|
});
|
|
4584
5408
|
continue;
|
|
4585
5409
|
}
|
|
4586
|
-
|
|
5410
|
+
const runState = classifyStrandedRunState(terminal);
|
|
5411
|
+
if (!runState) continue;
|
|
4587
5412
|
const config = parseJson(
|
|
4588
5413
|
raw.node_config_json,
|
|
4589
5414
|
{ approvers: [], behavior: "first_response" }
|
|
@@ -4606,6 +5431,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4606
5431
|
requestId: String(raw.id),
|
|
4607
5432
|
status: raw.status,
|
|
4608
5433
|
runId,
|
|
5434
|
+
runState,
|
|
4609
5435
|
flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
|
|
4610
5436
|
nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
|
|
4611
5437
|
objectName: raw.object_name,
|
|
@@ -4617,11 +5443,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4617
5443
|
});
|
|
4618
5444
|
}
|
|
4619
5445
|
if (stranded.length || undetermined) {
|
|
4620
|
-
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run
|
|
5446
|
+
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run unrecoverable)", {
|
|
4621
5447
|
scanned: rows.length,
|
|
4622
5448
|
stranded: stranded.length,
|
|
4623
5449
|
undetermined,
|
|
4624
|
-
|
|
5450
|
+
runMissing: stranded.filter((s) => s.runState === "missing").length,
|
|
5451
|
+
runFailed: stranded.filter((s) => s.runState === "failed").length,
|
|
5452
|
+
requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
|
|
4625
5453
|
});
|
|
4626
5454
|
}
|
|
4627
5455
|
return { scanned: rows.length, stranded, undetermined };
|
|
@@ -5124,8 +5952,70 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5124
5952
|
return { requests: desired.size, inserted, deleted };
|
|
5125
5953
|
}
|
|
5126
5954
|
// ── Read API ─────────────────────────────────────────────────
|
|
5955
|
+
/**
|
|
5956
|
+
* [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
|
|
5957
|
+
* for THIS caller over THIS query's scope?
|
|
5958
|
+
*
|
|
5959
|
+
* `payload_json` is the one searched column whose contents the serve path
|
|
5960
|
+
* masks per reader (`redactPayloads`, #10749). A predicate over it is
|
|
5961
|
+
* evaluated by the driver against the column AT REST — unmasked, before
|
|
5962
|
+
* anything is served — so for a caller whose view of the snapshot is masked,
|
|
5963
|
+
* row membership answers questions about contents that caller may not read.
|
|
5964
|
+
* The other four arms are columns of `sys_approval_request` itself, which
|
|
5965
|
+
* every caller who can see the row reads whole; they are untouched.
|
|
5966
|
+
*
|
|
5967
|
+
* ## The invariant this method exists to hold
|
|
5968
|
+
*
|
|
5969
|
+
* "This caller's view is masked" is read from **the same authority and the
|
|
5970
|
+
* same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
|
|
5971
|
+
* asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
|
|
5972
|
+
* notion of "redacted" — comparing the readable set against the object's
|
|
5973
|
+
* schema, say — would be a fresh source of drift, and drift between the
|
|
5974
|
+
* serve rule and the filter rule IS the defect this closes, reconstituted one
|
|
5975
|
+
* layer down. So the only "not masked" answer accepted here is the one serve
|
|
5976
|
+
* itself acts on: `undefined`, the seam's documented do-not-narrow branch.
|
|
5977
|
+
* When the seam holds a concrete list the mask is IN FORCE, whether or not it
|
|
5978
|
+
* happens to remove a key from any particular row — a row-dependent question
|
|
5979
|
+
* no predicate can answer before rows exist.
|
|
5980
|
+
*
|
|
5981
|
+
* ## The two predicate-time cases
|
|
5982
|
+
*
|
|
5983
|
+
* Redaction is decided per ROW (each row names its own subject object, hence
|
|
5984
|
+
* its own readable set), but a filter is built before any row exists:
|
|
5985
|
+
*
|
|
5986
|
+
* - **authority absent** — `resolveReadableSnapshotFields` answers
|
|
5987
|
+
* `undefined` for EVERY object, so serve hands over every snapshot whole.
|
|
5988
|
+
* Keeping the arm leaks nothing serve does not already hand over, and this
|
|
5989
|
+
* is the shape every deployment that has not wired the security plugin
|
|
5990
|
+
* gets: search is byte-for-byte unchanged. Checked first, and that order
|
|
5991
|
+
* is load-bearing — see the object-scope note below.
|
|
5992
|
+
* - **authority wired** — the readable set is per object, so the arm is
|
|
5993
|
+
* admissible only for a scope of exactly one KNOWN object. With
|
|
5994
|
+
* `filter.object` present that object is known at predicate time and the
|
|
5995
|
+
* seam is asked about it directly. Absent, the query spans every object
|
|
5996
|
+
* and there is nothing sound to ask, so the arm is dropped.
|
|
5997
|
+
*
|
|
5998
|
+
* Dropping an arm is strictly NARROWING: it never refuses a query and never
|
|
5999
|
+
* widens what comes back, so the fail-closed direction is cheap here and is
|
|
6000
|
+
* taken rather than reaching for a per-object predicate machine to avoid it.
|
|
6001
|
+
* Refusing the query outright would be the louder behaviour change, and is
|
|
6002
|
+
* deliberately not what this does.
|
|
6003
|
+
*/
|
|
6004
|
+
async freeTextMayMatchSnapshot(objectInScope, context) {
|
|
6005
|
+
const authority = this.fieldVisibility;
|
|
6006
|
+
if (!authority || typeof authority.getReadableFields !== "function") return true;
|
|
6007
|
+
const object = String(objectInScope ?? "").trim();
|
|
6008
|
+
if (!object) return false;
|
|
6009
|
+
const readable = await resolveReadableSnapshotFields(
|
|
6010
|
+
authority,
|
|
6011
|
+
object,
|
|
6012
|
+
context,
|
|
6013
|
+
this.logger
|
|
6014
|
+
);
|
|
6015
|
+
return readable === void 0;
|
|
6016
|
+
}
|
|
5127
6017
|
/** Filter type accepted by {@link listRequests} / {@link countRequests}. */
|
|
5128
|
-
buildRequestWhere(filter, context) {
|
|
6018
|
+
async buildRequestWhere(filter, context) {
|
|
5129
6019
|
const f = {};
|
|
5130
6020
|
if (filter?.object) f.object_name = filter.object;
|
|
5131
6021
|
if (filter?.recordId) f.record_id = filter.recordId;
|
|
@@ -5134,13 +6024,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5134
6024
|
if (tenantOrg) f.organization_id = tenantOrg;
|
|
5135
6025
|
const q = filter?.q?.trim();
|
|
5136
6026
|
if (q) {
|
|
5137
|
-
|
|
6027
|
+
const arms = [
|
|
5138
6028
|
{ process_name: { $contains: q } },
|
|
5139
6029
|
{ object_name: { $contains: q } },
|
|
5140
6030
|
{ record_id: { $contains: q } },
|
|
5141
|
-
{ submitter_id: { $contains: q } }
|
|
5142
|
-
{ payload_json: { $contains: q } }
|
|
6031
|
+
{ submitter_id: { $contains: q } }
|
|
5143
6032
|
];
|
|
6033
|
+
if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
|
|
6034
|
+
arms.push({ payload_json: { $contains: q } });
|
|
6035
|
+
}
|
|
6036
|
+
f.$or = arms;
|
|
5144
6037
|
}
|
|
5145
6038
|
if (Array.isArray(filter?.status)) {
|
|
5146
6039
|
const statuses = filter.status.filter(Boolean);
|
|
@@ -5343,7 +6236,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5343
6236
|
return true;
|
|
5344
6237
|
}
|
|
5345
6238
|
async listRequests(filter, context) {
|
|
5346
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
6239
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5347
6240
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5348
6241
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5349
6242
|
if (ids) {
|
|
@@ -5367,12 +6260,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5367
6260
|
}
|
|
5368
6261
|
const rows = await this.engine.find("sys_approval_request", findOpts);
|
|
5369
6262
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
6263
|
+
await this.redactPayloads(list, context);
|
|
5370
6264
|
await this.enrichRows(list);
|
|
5371
6265
|
this.attachViewers(list, context);
|
|
5372
6266
|
return list;
|
|
5373
6267
|
}
|
|
5374
6268
|
async countRequests(filter, context) {
|
|
5375
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
6269
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5376
6270
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5377
6271
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5378
6272
|
if (ids) {
|
|
@@ -5409,9 +6303,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5409
6303
|
* flow-driven resume, a service-to-service call), turning a successful write
|
|
5410
6304
|
* into a `null` result. Gating belongs on the read API, not on an
|
|
5411
6305
|
* operation's own return value.
|
|
6306
|
+
*
|
|
6307
|
+
* [#12769] The read still narrows by the CALLER's organization
|
|
6308
|
+
* (`loadRequest` — a deliberate tenancy wall, untouched here), and an
|
|
6309
|
+
* org-less request row is invisible inside an org-scoped caller's narrowing.
|
|
6310
|
+
* Org-less rows arise by construction on every schedule / time-relative /
|
|
6311
|
+
* api trigger run (#10131; #9132 pinned that behaviour rather than repairing
|
|
6312
|
+
* it), so this is a live state, not an edge case: the write IS recorded and
|
|
6313
|
+
* the echo cannot be built. It used to escape as `null` behind the callers'
|
|
6314
|
+
* `fresh!` non-null assertions — a well-formed 200 whose declared-non-null
|
|
6315
|
+
* `request` was `null` on the wire. Now it refuses loudly instead, which
|
|
6316
|
+
* keeps every declared result type true: a result that cannot be built is
|
|
6317
|
+
* never returned. Callers must NOT catch this to fall back to `null` —
|
|
6318
|
+
* that would re-open the type-lie one level up.
|
|
5412
6319
|
*/
|
|
5413
6320
|
async readBackRequest(requestId, context) {
|
|
5414
|
-
|
|
6321
|
+
const fresh = await this.loadRequest(requestId, context, false);
|
|
6322
|
+
if (!fresh) {
|
|
6323
|
+
throw new Error(
|
|
6324
|
+
`READ_BACK_FAILED: the write to approval request '${requestId}' was recorded, but the updated row is not visible inside the caller's organization scope, so the result envelope cannot be built. The write is NOT rolled back \u2014 read the request back with a system or matching-organization context.`
|
|
6325
|
+
);
|
|
6326
|
+
}
|
|
6327
|
+
return fresh;
|
|
5415
6328
|
}
|
|
5416
6329
|
async getRequest(requestId, context) {
|
|
5417
6330
|
return this.loadRequest(requestId, context, true);
|
|
@@ -5435,6 +6348,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5435
6348
|
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
5436
6349
|
}
|
|
5437
6350
|
const row = rowFromRequest(rows[0]);
|
|
6351
|
+
await this.redactPayloads([row], context);
|
|
5438
6352
|
await this.enrichRows([row]);
|
|
5439
6353
|
await this.attachFlowSteps(row);
|
|
5440
6354
|
await this.attachDecisionProgress(row, rows[0]);
|
|
@@ -5902,6 +6816,41 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
5902
6816
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
5903
6817
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
5904
6818
|
}
|
|
6819
|
+
var APPROVALS_OWN_OBJECTS = [
|
|
6820
|
+
"sys_approval_request",
|
|
6821
|
+
"sys_approval_action",
|
|
6822
|
+
"sys_approval_approver",
|
|
6823
|
+
"sys_approval_token",
|
|
6824
|
+
"sys_approval_delegation"
|
|
6825
|
+
];
|
|
6826
|
+
function bindRecordDeleteCancelHook(engine, service, logger) {
|
|
6827
|
+
engine.registerHook("afterDelete", async (ctx) => {
|
|
6828
|
+
const object = ctx?.object ?? ctx?.objectName;
|
|
6829
|
+
if (!object) return;
|
|
6830
|
+
const raw = ctx?.previous?.id ?? ctx?.input?.id;
|
|
6831
|
+
const recordId = raw == null ? "" : String(raw);
|
|
6832
|
+
if (!recordId) {
|
|
6833
|
+
logger?.warn?.(
|
|
6834
|
+
"[approvals] a delete reached the approval-cancel hook with no record id \u2014 pending approvals for the deleted record (if any) stay in the inbox",
|
|
6835
|
+
{ object }
|
|
6836
|
+
);
|
|
6837
|
+
return;
|
|
6838
|
+
}
|
|
6839
|
+
try {
|
|
6840
|
+
await service.cancelForDeletedRecord(String(object), recordId);
|
|
6841
|
+
} catch (err) {
|
|
6842
|
+
logger?.warn?.(
|
|
6843
|
+
"[approvals] the approval-cancel linkage failed for a deleted record \u2014 its pending approvals stay in the inbox pointing at a record that no longer exists",
|
|
6844
|
+
{ object, record: recordId, error: err?.message ?? String(err) }
|
|
6845
|
+
);
|
|
6846
|
+
}
|
|
6847
|
+
}, {
|
|
6848
|
+
excludeObjects: [...APPROVALS_OWN_OBJECTS],
|
|
6849
|
+
packageId: APPROVALS_HOOK_PACKAGE,
|
|
6850
|
+
priority: 50
|
|
6851
|
+
});
|
|
6852
|
+
logger?.info?.("[approvals] record-delete cancel hook bound");
|
|
6853
|
+
}
|
|
5905
6854
|
var DELEGATION_OBJECT = "sys_approval_delegation";
|
|
5906
6855
|
function bindDelegationWriteGuard(engine, logger) {
|
|
5907
6856
|
const makeGuard = (isInsert) => async (ctx) => {
|
|
@@ -5938,6 +6887,52 @@ function unbindAllHooks(engine) {
|
|
|
5938
6887
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
5939
6888
|
}
|
|
5940
6889
|
|
|
6890
|
+
// src/payload-redaction-middleware.ts
|
|
6891
|
+
var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
|
|
6892
|
+
function parseSnapshot(raw) {
|
|
6893
|
+
if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
|
|
6894
|
+
try {
|
|
6895
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
6896
|
+
} catch {
|
|
6897
|
+
return { ok: false, value: void 0 };
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
async function redactRowsInPlace(rows, security, context, logger) {
|
|
6901
|
+
const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
|
|
6902
|
+
if (list.length === 0) return;
|
|
6903
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6904
|
+
for (const row of list) {
|
|
6905
|
+
if (!row || typeof row !== "object") continue;
|
|
6906
|
+
const raw = row.payload_json;
|
|
6907
|
+
const parsed = parseSnapshot(raw);
|
|
6908
|
+
if (!parsed.ok) continue;
|
|
6909
|
+
const object = String(row.object_name ?? "").trim();
|
|
6910
|
+
if (!object) continue;
|
|
6911
|
+
if (!cache.has(object)) {
|
|
6912
|
+
cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
|
|
6913
|
+
}
|
|
6914
|
+
const readable = cache.get(object);
|
|
6915
|
+
if (readable === void 0) continue;
|
|
6916
|
+
const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
|
|
6917
|
+
if (redactedKeys.length === 0) continue;
|
|
6918
|
+
row.payload_json = JSON.stringify(payload);
|
|
6919
|
+
}
|
|
6920
|
+
}
|
|
6921
|
+
function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
|
|
6922
|
+
engine.registerMiddleware(async (opCtx, next) => {
|
|
6923
|
+
await next();
|
|
6924
|
+
if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
|
|
6925
|
+
if (opCtx?.context?.isSystem) return;
|
|
6926
|
+
try {
|
|
6927
|
+
await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
|
|
6928
|
+
} catch (err) {
|
|
6929
|
+
logger?.warn?.("[approvals] snapshot redaction middleware failed", {
|
|
6930
|
+
error: err?.message ?? String(err)
|
|
6931
|
+
});
|
|
6932
|
+
}
|
|
6933
|
+
}, { object: APPROVAL_REQUEST_OBJECT });
|
|
6934
|
+
}
|
|
6935
|
+
|
|
5941
6936
|
// src/approval-node.ts
|
|
5942
6937
|
import {
|
|
5943
6938
|
defineActionDescriptor as defineActionDescriptor2,
|
|
@@ -6207,13 +7202,46 @@ var ApprovalsServicePlugin = class {
|
|
|
6207
7202
|
} catch {
|
|
6208
7203
|
return void 0;
|
|
6209
7204
|
}
|
|
7205
|
+
},
|
|
7206
|
+
// [#11993] Deployment override lookup for user-facing refusal copy.
|
|
7207
|
+
// Resolved LAZILY for the reason the two providers above are: the i18n
|
|
7208
|
+
// service is contributed by another plugin (ADR-0029 D8) and may start
|
|
7209
|
+
// after this one. Without it the service still renders the built-in
|
|
7210
|
+
// catalog in the caller's locale; with it, a deployment's own
|
|
7211
|
+
// `translation` for `errors.approval_recall_not_submitter` wins — the
|
|
7212
|
+
// override address the catalog documents, made real for this emitter.
|
|
7213
|
+
messageTranslator: () => {
|
|
7214
|
+
try {
|
|
7215
|
+
const i18n = ctx.getService("i18n");
|
|
7216
|
+
const t = i18n?.t;
|
|
7217
|
+
if (typeof t !== "function") return void 0;
|
|
7218
|
+
return (key, locale, params) => t.call(i18n, key, locale, params);
|
|
7219
|
+
} catch {
|
|
7220
|
+
return void 0;
|
|
7221
|
+
}
|
|
7222
|
+
}
|
|
7223
|
+
});
|
|
7224
|
+
const fieldVisibility = () => {
|
|
7225
|
+
try {
|
|
7226
|
+
const sec = ctx.getService("security");
|
|
7227
|
+
return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
|
|
7228
|
+
} catch {
|
|
7229
|
+
return void 0;
|
|
7230
|
+
}
|
|
7231
|
+
};
|
|
7232
|
+
this.service.attachFieldVisibility({
|
|
7233
|
+
getReadableFields: (object, context) => {
|
|
7234
|
+
const sec = fieldVisibility();
|
|
7235
|
+
return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
|
|
6210
7236
|
}
|
|
6211
7237
|
});
|
|
6212
7238
|
if (!this.options.disableAutoHooks) {
|
|
6213
7239
|
try {
|
|
6214
7240
|
unbindAllHooks(engine);
|
|
6215
7241
|
bindApprovalLockHook(engine, ctx.logger);
|
|
7242
|
+
bindRecordDeleteCancelHook(engine, this.service, ctx.logger);
|
|
6216
7243
|
bindDelegationWriteGuard(engine, ctx.logger);
|
|
7244
|
+
bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
|
|
6217
7245
|
} catch (err) {
|
|
6218
7246
|
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
6219
7247
|
}
|
|
@@ -6253,6 +7281,7 @@ var ApprovalsServicePlugin = class {
|
|
|
6253
7281
|
}
|
|
6254
7282
|
};
|
|
6255
7283
|
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
7284
|
+
this.jobService = jobs;
|
|
6256
7285
|
this.escalationJobScheduled = true;
|
|
6257
7286
|
void sweep().catch((err) => {
|
|
6258
7287
|
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
@@ -6339,14 +7368,32 @@ var ApprovalsServicePlugin = class {
|
|
|
6339
7368
|
);
|
|
6340
7369
|
}
|
|
6341
7370
|
}
|
|
6342
|
-
|
|
7371
|
+
/**
|
|
7372
|
+
* The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
|
|
7373
|
+
* ONLY teardown entry point `ObjectKernel.performShutdown()` and
|
|
7374
|
+
* `LiteKernel.destroy()` invoke.
|
|
7375
|
+
*
|
|
7376
|
+
* [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
|
|
7377
|
+
* `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
|
|
7378
|
+
* past this plugin at shutdown: the SLA escalation job stayed scheduled and
|
|
7379
|
+
* this plugin's ObjectQL hooks stayed bound to an engine the kernel had
|
|
7380
|
+
* finished with. `start()` IS on the interface, so the pair read as symmetric
|
|
7381
|
+
* in review — that asymmetry is what let the same shape survive in six
|
|
7382
|
+
* packages at once.
|
|
7383
|
+
*
|
|
7384
|
+
* This member owns no timer of its own (the escalation clock belongs to
|
|
7385
|
+
* `service-job`), so it never cost a merge-queue eviction the way the
|
|
7386
|
+
* `plugin-reports` / `service-messaging` members did (#9371). The class is
|
|
7387
|
+
* the same one either way: a teardown the kernel does not reach.
|
|
7388
|
+
*/
|
|
7389
|
+
async destroy() {
|
|
6343
7390
|
if (this.escalationJobScheduled) {
|
|
6344
7391
|
try {
|
|
6345
|
-
|
|
6346
|
-
await jobs?.cancel?.(ESCALATION_JOB_NAME);
|
|
7392
|
+
await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
|
|
6347
7393
|
} catch {
|
|
6348
7394
|
}
|
|
6349
7395
|
this.escalationJobScheduled = false;
|
|
7396
|
+
this.jobService = void 0;
|
|
6350
7397
|
}
|
|
6351
7398
|
if (this.engine) {
|
|
6352
7399
|
try {
|
|
@@ -6355,6 +7402,17 @@ var ApprovalsServicePlugin = class {
|
|
|
6355
7402
|
}
|
|
6356
7403
|
}
|
|
6357
7404
|
}
|
|
7405
|
+
/**
|
|
7406
|
+
* Retained alias for {@link destroy}. Kept because it is public API of an
|
|
7407
|
+
* exported class, and removing it would break an embedder who learned to call
|
|
7408
|
+
* it directly precisely BECAUSE the kernel never did. The parameter is now
|
|
7409
|
+
* optional and ignored: `destroy()` takes no context, so teardown uses the
|
|
7410
|
+
* job service captured when the escalation clock was wired. Prefer kernel
|
|
7411
|
+
* shutdown; direct callers keep working unchanged.
|
|
7412
|
+
*/
|
|
7413
|
+
async stop(_ctx) {
|
|
7414
|
+
await this.destroy();
|
|
7415
|
+
}
|
|
6358
7416
|
};
|
|
6359
7417
|
export {
|
|
6360
7418
|
APPROVAL_REVISE_CORRELATION_PREFIX,
|