@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.js
CHANGED
|
@@ -41,7 +41,7 @@ var init_en_objects_generated = __esm({
|
|
|
41
41
|
},
|
|
42
42
|
organization_id: {
|
|
43
43
|
label: "Organization",
|
|
44
|
-
help: "
|
|
44
|
+
help: "Organization of the record this request is about (falls back to the acting context when the record has none)"
|
|
45
45
|
},
|
|
46
46
|
process_name: {
|
|
47
47
|
label: "Source",
|
|
@@ -67,7 +67,15 @@ var init_en_objects_generated = __esm({
|
|
|
67
67
|
approved: "Approved",
|
|
68
68
|
rejected: "Rejected",
|
|
69
69
|
recalled: "Recalled",
|
|
70
|
-
returned: "Returned"
|
|
70
|
+
returned: "Returned",
|
|
71
|
+
cancelled: "Cancelled"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
cancel_reason: {
|
|
75
|
+
label: "Cancellation Reason",
|
|
76
|
+
help: "Why the platform voided this request (set only when the status is cancelled)",
|
|
77
|
+
options: {
|
|
78
|
+
record_deleted: "Related record deleted"
|
|
71
79
|
}
|
|
72
80
|
},
|
|
73
81
|
current_step: {
|
|
@@ -248,7 +256,8 @@ var init_en_objects_generated = __esm({
|
|
|
248
256
|
comment: "Comment",
|
|
249
257
|
revise: "Revise",
|
|
250
258
|
resubmit: "Resubmit",
|
|
251
|
-
ooo_substitute: "Out-of-Office Substitution"
|
|
259
|
+
ooo_substitute: "Out-of-Office Substitution",
|
|
260
|
+
cancel: "Cancel"
|
|
252
261
|
}
|
|
253
262
|
},
|
|
254
263
|
actor_id: {
|
|
@@ -362,7 +371,7 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
362
371
|
},
|
|
363
372
|
organization_id: {
|
|
364
373
|
label: "\u7EC4\u7EC7",
|
|
365
|
-
help: "\
|
|
374
|
+
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"
|
|
366
375
|
},
|
|
367
376
|
process_name: {
|
|
368
377
|
label: "\u6765\u6E90",
|
|
@@ -388,7 +397,15 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
388
397
|
approved: "\u5DF2\u6279\u51C6",
|
|
389
398
|
rejected: "\u5DF2\u62D2\u7EDD",
|
|
390
399
|
recalled: "\u5DF2\u64A4\u56DE",
|
|
391
|
-
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
|
|
400
|
+
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539",
|
|
401
|
+
cancelled: "\u5DF2\u4F5C\u5E9F"
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
cancel_reason: {
|
|
405
|
+
label: "\u4F5C\u5E9F\u539F\u56E0",
|
|
406
|
+
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)",
|
|
407
|
+
options: {
|
|
408
|
+
record_deleted: "\u5173\u8054\u8BB0\u5F55\u5DF2\u5220\u9664"
|
|
392
409
|
}
|
|
393
410
|
},
|
|
394
411
|
current_step: {
|
|
@@ -569,7 +586,8 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
569
586
|
comment: "\u8BC4\u8BBA",
|
|
570
587
|
revise: "\u9000\u56DE\u4FEE\u6539",
|
|
571
588
|
resubmit: "\u91CD\u65B0\u63D0\u4EA4",
|
|
572
|
-
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
|
|
589
|
+
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E",
|
|
590
|
+
cancel: "\u4F5C\u5E9F"
|
|
573
591
|
}
|
|
574
592
|
},
|
|
575
593
|
actor_id: {
|
|
@@ -683,7 +701,7 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
683
701
|
},
|
|
684
702
|
organization_id: {
|
|
685
703
|
label: "\u7D44\u7E54",
|
|
686
|
-
help: "\u3053\u306E\
|
|
704
|
+
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"
|
|
687
705
|
},
|
|
688
706
|
process_name: {
|
|
689
707
|
label: "\u30BD\u30FC\u30B9",
|
|
@@ -709,7 +727,15 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
709
727
|
approved: "\u627F\u8A8D\u6E08\u307F",
|
|
710
728
|
rejected: "\u5374\u4E0B\u6E08\u307F",
|
|
711
729
|
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
|
|
712
|
-
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
|
|
730
|
+
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F",
|
|
731
|
+
cancelled: "\u7121\u52B9\u5316\u6E08\u307F"
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
cancel_reason: {
|
|
735
|
+
label: "\u7121\u52B9\u5316\u7406\u7531",
|
|
736
|
+
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)",
|
|
737
|
+
options: {
|
|
738
|
+
record_deleted: "\u95A2\u9023\u30EC\u30B3\u30FC\u30C9\u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F"
|
|
713
739
|
}
|
|
714
740
|
},
|
|
715
741
|
current_step: {
|
|
@@ -890,7 +916,8 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
890
916
|
comment: "\u30B3\u30E1\u30F3\u30C8",
|
|
891
917
|
revise: "\u5DEE\u3057\u623B\u3057",
|
|
892
918
|
resubmit: "\u518D\u63D0\u51FA",
|
|
893
|
-
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
|
|
919
|
+
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406",
|
|
920
|
+
cancel: "\u7121\u52B9\u5316"
|
|
894
921
|
}
|
|
895
922
|
},
|
|
896
923
|
actor_id: {
|
|
@@ -1004,7 +1031,7 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1004
1031
|
},
|
|
1005
1032
|
organization_id: {
|
|
1006
1033
|
label: "Organizaci\xF3n",
|
|
1007
|
-
help: "
|
|
1034
|
+
help: "Organizaci\xF3n del registro al que se refiere esta solicitud (recurre al contexto del actor cuando el registro no tiene ninguna)."
|
|
1008
1035
|
},
|
|
1009
1036
|
process_name: {
|
|
1010
1037
|
label: "Origen",
|
|
@@ -1030,7 +1057,15 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1030
1057
|
approved: "Aprobada",
|
|
1031
1058
|
rejected: "Rechazada",
|
|
1032
1059
|
recalled: "Retirada",
|
|
1033
|
-
returned: "Devuelta para revisi\xF3n"
|
|
1060
|
+
returned: "Devuelta para revisi\xF3n",
|
|
1061
|
+
cancelled: "Anulada"
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
cancel_reason: {
|
|
1065
|
+
label: "Motivo de anulaci\xF3n",
|
|
1066
|
+
help: "Por qu\xE9 la plataforma anul\xF3 esta solicitud (solo se establece cuando el estado es Anulada)",
|
|
1067
|
+
options: {
|
|
1068
|
+
record_deleted: "Registro relacionado eliminado"
|
|
1034
1069
|
}
|
|
1035
1070
|
},
|
|
1036
1071
|
current_step: {
|
|
@@ -1211,7 +1246,8 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1211
1246
|
comment: "Comentario",
|
|
1212
1247
|
revise: "Devoluci\xF3n",
|
|
1213
1248
|
resubmit: "Reenv\xEDo",
|
|
1214
|
-
ooo_substitute: "Sustituci\xF3n por ausencia"
|
|
1249
|
+
ooo_substitute: "Sustituci\xF3n por ausencia",
|
|
1250
|
+
cancel: "Anulaci\xF3n"
|
|
1215
1251
|
}
|
|
1216
1252
|
},
|
|
1217
1253
|
actor_id: {
|
|
@@ -1309,24 +1345,120 @@ var init_es_ES_objects_generated = __esm({
|
|
|
1309
1345
|
}
|
|
1310
1346
|
});
|
|
1311
1347
|
|
|
1348
|
+
// src/translations/zh-CN.source-hashes.generated.ts
|
|
1349
|
+
var zhCNGeneratedSourceHashes;
|
|
1350
|
+
var init_zh_CN_source_hashes_generated = __esm({
|
|
1351
|
+
"src/translations/zh-CN.source-hashes.generated.ts"() {
|
|
1352
|
+
"use strict";
|
|
1353
|
+
zhCNGeneratedSourceHashes = {
|
|
1354
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1355
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1356
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1357
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1358
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1359
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
// src/translations/ja-JP.source-hashes.generated.ts
|
|
1365
|
+
var jaJPGeneratedSourceHashes;
|
|
1366
|
+
var init_ja_JP_source_hashes_generated = __esm({
|
|
1367
|
+
"src/translations/ja-JP.source-hashes.generated.ts"() {
|
|
1368
|
+
"use strict";
|
|
1369
|
+
jaJPGeneratedSourceHashes = {
|
|
1370
|
+
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
|
|
1371
|
+
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
|
|
1372
|
+
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
|
|
1373
|
+
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
|
|
1374
|
+
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
|
|
1375
|
+
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
|
|
1376
|
+
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
|
|
1377
|
+
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
|
|
1378
|
+
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
|
|
1379
|
+
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
|
|
1380
|
+
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
|
|
1381
|
+
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
|
|
1382
|
+
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
|
|
1383
|
+
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
|
|
1384
|
+
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
|
|
1385
|
+
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
|
|
1386
|
+
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
|
|
1387
|
+
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
|
|
1388
|
+
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
|
|
1389
|
+
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
|
|
1390
|
+
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
|
|
1391
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1392
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1393
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1394
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1395
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1396
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
// src/translations/es-ES.source-hashes.generated.ts
|
|
1402
|
+
var esESGeneratedSourceHashes;
|
|
1403
|
+
var init_es_ES_source_hashes_generated = __esm({
|
|
1404
|
+
"src/translations/es-ES.source-hashes.generated.ts"() {
|
|
1405
|
+
"use strict";
|
|
1406
|
+
esESGeneratedSourceHashes = {
|
|
1407
|
+
"objects.sys_approval_action.fields.actor_id.label": "b155813f8a7f06e3",
|
|
1408
|
+
"objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
|
|
1409
|
+
"objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
|
|
1410
|
+
"objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
|
|
1411
|
+
"objects.sys_approval_delegation.description": "432b0435aa15f6b9",
|
|
1412
|
+
"objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
|
|
1413
|
+
"objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
|
|
1414
|
+
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
|
|
1415
|
+
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
|
|
1416
|
+
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
|
|
1417
|
+
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
|
|
1418
|
+
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
|
|
1419
|
+
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
|
|
1420
|
+
"objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
|
|
1421
|
+
"objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
|
|
1422
|
+
"objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
|
|
1423
|
+
"objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
|
|
1424
|
+
"objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
|
|
1425
|
+
"objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
|
|
1426
|
+
"objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
|
|
1427
|
+
"objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
|
|
1428
|
+
"objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
|
|
1429
|
+
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
|
|
1430
|
+
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
|
|
1431
|
+
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
|
|
1432
|
+
"objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
|
|
1433
|
+
"objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
|
|
1434
|
+
"objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1312
1439
|
// src/translations/index.ts
|
|
1313
1440
|
var translations_exports = {};
|
|
1314
1441
|
__export(translations_exports, {
|
|
1315
1442
|
ApprovalsTranslations: () => ApprovalsTranslations
|
|
1316
1443
|
});
|
|
1317
|
-
var ApprovalsTranslations;
|
|
1444
|
+
var import_apps, enSource, ApprovalsTranslations;
|
|
1318
1445
|
var init_translations = __esm({
|
|
1319
1446
|
"src/translations/index.ts"() {
|
|
1320
1447
|
"use strict";
|
|
1448
|
+
import_apps = require("@objectstack/platform-objects/apps");
|
|
1321
1449
|
init_en_objects_generated();
|
|
1322
1450
|
init_zh_CN_objects_generated();
|
|
1323
1451
|
init_ja_JP_objects_generated();
|
|
1324
1452
|
init_es_ES_objects_generated();
|
|
1453
|
+
init_zh_CN_source_hashes_generated();
|
|
1454
|
+
init_ja_JP_source_hashes_generated();
|
|
1455
|
+
init_es_ES_source_hashes_generated();
|
|
1456
|
+
enSource = { objects: enObjects };
|
|
1325
1457
|
ApprovalsTranslations = {
|
|
1326
|
-
en:
|
|
1327
|
-
"zh-CN": { objects: zhCNObjects },
|
|
1328
|
-
"ja-JP": { objects: jaJPObjects },
|
|
1329
|
-
"es-ES": { objects: esESObjects }
|
|
1458
|
+
en: enSource,
|
|
1459
|
+
"zh-CN": (0, import_apps.withSourceFallback)({ objects: zhCNObjects }, enSource, void 0, zhCNGeneratedSourceHashes),
|
|
1460
|
+
"ja-JP": (0, import_apps.withSourceFallback)({ objects: jaJPObjects }, enSource, void 0, jaJPGeneratedSourceHashes),
|
|
1461
|
+
"es-ES": (0, import_apps.withSourceFallback)({ objects: esESObjects }, enSource, void 0, esESGeneratedSourceHashes)
|
|
1330
1462
|
};
|
|
1331
1463
|
}
|
|
1332
1464
|
});
|
|
@@ -1395,7 +1527,10 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1395
1527
|
label: "Completed",
|
|
1396
1528
|
data: { provider: "object", object: "sys_approval_request" },
|
|
1397
1529
|
columns: ["process_name", "object_name", "record_id", "status", "submitter_id", "completed_at"],
|
|
1398
|
-
|
|
1530
|
+
// Every terminal state, `cancelled` included (#13568) — a platform-voided
|
|
1531
|
+
// request is kept as audit evidence, and a terminal state absent from the
|
|
1532
|
+
// only curated terminal view is evidence nobody can find.
|
|
1533
|
+
filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled", "cancelled"] }],
|
|
1399
1534
|
sort: [{ field: "completed_at", order: "desc" }],
|
|
1400
1535
|
pagination: { pageSize: 25 }
|
|
1401
1536
|
},
|
|
@@ -1411,46 +1546,40 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1411
1546
|
},
|
|
1412
1547
|
fields: {
|
|
1413
1548
|
id: import_data.Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
|
|
1414
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
// whatever filter each surface happens to apply rather than by the data.
|
|
1421
|
-
//
|
|
1422
|
-
// The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
|
|
1423
|
-
// `ctxOrg`), so it is NULL whenever the flow that opened the request ran
|
|
1424
|
-
// without one — every schedule / time-relative / api triggered run, none of
|
|
1425
|
-
// which sets a tenant. On a walled single-database HotCRM SaaS boot this
|
|
1426
|
-
// measured 27 of 27 rows org-less, each naming an `object_name` /
|
|
1427
|
-
// `record_id` owned by a specific customer.
|
|
1549
|
+
// [#10101, the cloud#1395 Option A ruling] The SUBJECT record's
|
|
1550
|
+
// organization, with the acting context as fallback — resolved by the
|
|
1551
|
+
// SHARED platform-row resolver (`resolveRecordOrganizationField`,
|
|
1552
|
+
// `@objectstack/metadata-core`) in `openNodeRequest`, the row's only
|
|
1553
|
+
// writer. The same `requestOrg` stamps `sys_approval_action` and the
|
|
1554
|
+
// `sys_approval_approver` index, so all three move together.
|
|
1428
1555
|
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1556
|
+
// Why subject-first: an approval request is read through the organization
|
|
1557
|
+
// wall by the approvals inbox, and the acting context is NULL on every
|
|
1558
|
+
// schedule / time-relative / api triggered run (none carries a tenant, by
|
|
1559
|
+
// construction). Stamped from the actor alone — the pre-#10101 behaviour,
|
|
1560
|
+
// measured on cloud#1395 as 27 of 27 rows org-less on a walled HotCRM SaaS
|
|
1561
|
+
// boot — such a request LOCKED the record it was about while being
|
|
1562
|
+
// invisible in every inbox, its owner's included. Subject-first is also
|
|
1563
|
+
// what `sys_audit_log`'s writer already did (#8707 honouring #8287's
|
|
1564
|
+
// ruling), so an approval row and an audit row about the same record now
|
|
1565
|
+
// land behind the same wall instead of two.
|
|
1436
1566
|
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1567
|
+
// The `sys_api_key` divergence is deliberate and preserved: its
|
|
1568
|
+
// `tenancy.organizationField: 'active_organization_id'` (stamp-only,
|
|
1569
|
+
// #8778) wins limb 0 of the shared resolver, while the credential table
|
|
1570
|
+
// itself stays unwalled (`tenancy.enabled: false`) — who a row is ABOUT
|
|
1571
|
+
// and what an object is WALLED by remain different questions.
|
|
1442
1572
|
organization_id: import_data.Field.lookup("sys_organization", {
|
|
1443
1573
|
label: "Organization",
|
|
1444
1574
|
required: false,
|
|
1445
1575
|
group: "System",
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
|
|
1453
|
-
description: "Tenant that owns this approval request (propagated from submitter context)"
|
|
1576
|
+
// Reworded with the #10101 write-side fix (was "Tenant that owns this
|
|
1577
|
+
// approval request (propagated from submitter context)" — it claimed a
|
|
1578
|
+
// propagation that measurably did not happen, and said "Tenant" where
|
|
1579
|
+
// ADR-0120 §Terminology requires "organization"). Extracted into the
|
|
1580
|
+
// generated i18n bundles as `help`; the four locales regenerate in the
|
|
1581
|
+
// same pass.
|
|
1582
|
+
description: "Organization of the record this request is about (falls back to the acting context when the record has none)"
|
|
1454
1583
|
}),
|
|
1455
1584
|
process_name: import_data.Field.text({
|
|
1456
1585
|
label: "Source",
|
|
@@ -1469,7 +1598,38 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1469
1598
|
label: "Record ID",
|
|
1470
1599
|
required: true,
|
|
1471
1600
|
maxLength: 100,
|
|
1472
|
-
|
|
1601
|
+
// [#11386] The id half of this object's pointer pair (ADR-0052 §5),
|
|
1602
|
+
// adopting the #11339 carrier. VERIFIED for THIS object: the pair is not
|
|
1603
|
+
// decoration but the key the approval machinery QUERIES ON —
|
|
1604
|
+
// `approval-service.ts` finds a record's pending request with
|
|
1605
|
+
// `where: { object_name, record_id, status: 'pending' }`, and
|
|
1606
|
+
// `lifecycle-hooks.ts` holds the record LOCK on the same pair
|
|
1607
|
+
// (single-record and `$in` batch forms). `submit()` writes it from
|
|
1608
|
+
// `input.object` / `input.recordId`, so a stored value is always a
|
|
1609
|
+
// record id of the object the sibling names.
|
|
1610
|
+
//
|
|
1611
|
+
// Consequence of declaring, sharper here than elsewhere: a seeded
|
|
1612
|
+
// request whose `record_id` stayed a verbatim natural key locked
|
|
1613
|
+
// NOTHING and appeared under no record — it looked like a pending
|
|
1614
|
+
// approval while being invisible to both queries that give the row its
|
|
1615
|
+
// meaning. That is now a loud seed-time refusal. Both halves are
|
|
1616
|
+
// `required: true`, so the un-addressable case (id half authored, type
|
|
1617
|
+
// half empty) is already unreachable on this object.
|
|
1618
|
+
group: "Target",
|
|
1619
|
+
//
|
|
1620
|
+
// ⚠️ ORDERING CONSTRAINT — this id half is `required: true`, and that
|
|
1621
|
+
// makes it ORDER-DEPENDENT in seeds even though a pointer pair
|
|
1622
|
+
// contributes no static ordering edge (#11674, measured against the real
|
|
1623
|
+
// engine in `packages/objectql/src/engine-seed-required-deferral.test.ts`):
|
|
1624
|
+
// the seed loader defers an unresolvable reference by DELETING the column
|
|
1625
|
+
// from the pass-1 insert, required-validation rejects that row, and pass 2
|
|
1626
|
+
// is then left with no row to back-fill. So the pass-2 healing that makes
|
|
1627
|
+
// an OPTIONAL id half order-independent (`sys_audit_log`) does not reach
|
|
1628
|
+
// this one. ⇒ SEED THE TARGET DATASET FIRST. The failure if you do not is
|
|
1629
|
+
// loud in three places — a write error naming this column, a
|
|
1630
|
+
// dropped-deferral error, and `success: false` — and since #11674 the
|
|
1631
|
+
// loader also WARNS at load time, before the engine rejects the row.
|
|
1632
|
+
referenceVia: "object_name"
|
|
1473
1633
|
}),
|
|
1474
1634
|
submitter_id: import_data.Field.lookup("sys_user", {
|
|
1475
1635
|
label: "Submitter",
|
|
@@ -1497,6 +1657,29 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1497
1657
|
group: "State"
|
|
1498
1658
|
}
|
|
1499
1659
|
),
|
|
1660
|
+
// [#13568] Why a `cancelled` request was cancelled — the machine-readable
|
|
1661
|
+
// half of the maintainer's 2026-08-31 ruling ("状态转 cancelled + 机器可读
|
|
1662
|
+
// 原因"). Derived from `APPROVAL_CANCEL_REASONS`, never re-typed, on the
|
|
1663
|
+
// same #3786 rule the `status` column above follows.
|
|
1664
|
+
//
|
|
1665
|
+
// On the ROW rather than on the `sys_approval_action` audit entry, because
|
|
1666
|
+
// the readers are the inbox and the tombstone presentation, which page the
|
|
1667
|
+
// request table: making them join the append-only action log to learn why a
|
|
1668
|
+
// row they already hold is cancelled buys nothing and would make the reason
|
|
1669
|
+
// unavailable to a plain list view. The action row still records the event
|
|
1670
|
+
// (`action: 'cancel'`); this column records the STATE.
|
|
1671
|
+
//
|
|
1672
|
+
// Null on every non-cancelled row, and on rows written before the column
|
|
1673
|
+
// existed — "not recorded", never "cancelled for no reason".
|
|
1674
|
+
cancel_reason: import_data.Field.select(
|
|
1675
|
+
import_contracts.APPROVAL_CANCEL_REASONS.map((value) => ({ value, label: import_contracts.APPROVAL_CANCEL_REASON_LABELS[value] })),
|
|
1676
|
+
{
|
|
1677
|
+
label: "Cancellation Reason",
|
|
1678
|
+
required: false,
|
|
1679
|
+
description: "Why the platform voided this request (set only when the status is cancelled)",
|
|
1680
|
+
group: "State"
|
|
1681
|
+
}
|
|
1682
|
+
),
|
|
1500
1683
|
current_step: import_data.Field.text({
|
|
1501
1684
|
label: "Current Step",
|
|
1502
1685
|
required: false,
|
|
@@ -1516,6 +1699,22 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1516
1699
|
description: "Comma-separated user ids who can act on the current step",
|
|
1517
1700
|
group: "State"
|
|
1518
1701
|
}),
|
|
1702
|
+
// The module docstring above used to justify this column with "used by
|
|
1703
|
+
// notifications so they can render before the record is locked or
|
|
1704
|
+
// changed". That consumer does not exist, and the claim was cited as the
|
|
1705
|
+
// reason the column holds a FULL row before anyone checked it. Measured
|
|
1706
|
+
// against every `this.notify(...)` call site in `approval-service.ts` —
|
|
1707
|
+
// all 12 of them: each passes `{ title, message, actionUrl }` (two also
|
|
1708
|
+
// `actions`), built from `object_name` / `record_id` and the caller's
|
|
1709
|
+
// comment. None reads this column or the parsed `payload`. The real
|
|
1710
|
+
// readers are the serve path (`rowFromRequest` -> `payload`, redacted per
|
|
1711
|
+
// reader), the decide-time approver re-resolution and the org backfill,
|
|
1712
|
+
// both under SYSTEM_CTX, and the free-text predicate.
|
|
1713
|
+
//
|
|
1714
|
+
// The `description` below is deliberately UNCHANGED: it is accurate, and
|
|
1715
|
+
// it is extracted into the four generated i18n bundles as `help`. The
|
|
1716
|
+
// false sentence lived only in the JSDoc above, which is not extracted —
|
|
1717
|
+
// so this correction moves no translation leaf.
|
|
1519
1718
|
payload_json: import_data.Field.textarea({
|
|
1520
1719
|
label: "Snapshot",
|
|
1521
1720
|
required: false,
|
|
@@ -1586,11 +1785,14 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1586
1785
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1587
1786
|
// (the caller is a current pending approver — same check the service
|
|
1588
1787
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1589
|
-
// submitter actions on `record.viewer.is_submitter`. The
|
|
1590
|
-
// (approve/reject/reassign
|
|
1591
|
-
//
|
|
1788
|
+
// submitter actions on `record.viewer.is_submitter`. The four levers the
|
|
1789
|
+
// #3424 override covers (approve/reject/reassign, and recall since #12716)
|
|
1790
|
+
// additionally OR in `record.viewer.can_override`
|
|
1791
|
+
// so a platform/tenant admin can rescue a request routed to an
|
|
1592
1792
|
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
1593
|
-
// approving, rejecting,
|
|
1793
|
+
// approving, rejecting, reassigning it to a real approver, or recalling it
|
|
1794
|
+
// (the lever that releases the record without recording a decision nobody
|
|
1795
|
+
// made). `viewer` is
|
|
1594
1796
|
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1595
1797
|
// closed.
|
|
1596
1798
|
//
|
|
@@ -1726,8 +1928,13 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1726
1928
|
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
1727
1929
|
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
|
|
1728
1930
|
// (server-computed on the current viewer). The service re-checks ownership;
|
|
1729
|
-
// the predicate keeps a non-submitter from ever seeing a button they
|
|
1730
|
-
// use.
|
|
1931
|
+
// the predicate keeps a plain non-submitter from ever seeing a button they
|
|
1932
|
+
// cannot use.
|
|
1933
|
+
//
|
|
1934
|
+
// `recall` is the one exception, and it is not a widening: it ALSO ORs in
|
|
1935
|
+
// the #3424 admin override (#12716), because an override admin is a caller
|
|
1936
|
+
// `ApprovalService.recall` already authorises. Remind and resubmit keep no
|
|
1937
|
+
// override arm.
|
|
1731
1938
|
{
|
|
1732
1939
|
name: "approval_remind",
|
|
1733
1940
|
label: "Send reminder",
|
|
@@ -1758,7 +1965,26 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1758
1965
|
],
|
|
1759
1966
|
// Recall applies while the request is live for the submitter — pending
|
|
1760
1967
|
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1761
|
-
|
|
1968
|
+
//
|
|
1969
|
+
// The second arm is the #3424 admin override, spelled byte-identically to
|
|
1970
|
+
// the three core decision levers above (#12716). `ApprovalService.recall`
|
|
1971
|
+
// has admitted the override caller since #3424 — `isOverrideActor`'s own
|
|
1972
|
+
// doc block names recall as one of the four levers — so until this arm
|
|
1973
|
+
// landed, recall was the one authorised capability with no button: an
|
|
1974
|
+
// admin could approve or reject their way out of a stuck request (writing
|
|
1975
|
+
// a decision that did not happen) or reassign it, but could not withdraw.
|
|
1976
|
+
//
|
|
1977
|
+
// The override arm carries no status test of its own, on purpose, because
|
|
1978
|
+
// it does not need one and the siblings do not have one either: the flag
|
|
1979
|
+
// is already status-scoped where it is COMPUTED. `attachViewers` in
|
|
1980
|
+
// `approval-service.ts` sets
|
|
1981
|
+
// `can_override: row.status === 'pending' && isOverrideActor(...)` —
|
|
1982
|
+
// ANDed — so `record.viewer.can_override` can never be true off `pending`,
|
|
1983
|
+
// and this arm is pending-only in effect however CEL groups the
|
|
1984
|
+
// expression. Pinned in both directions in
|
|
1985
|
+
// `action-predicate-sparse-face.test.ts`, with the flag's own scoping
|
|
1986
|
+
// pinned against the real service in `approval-revise.test.ts`.
|
|
1987
|
+
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',
|
|
1762
1988
|
locations: ["record_section"],
|
|
1763
1989
|
successMessage: "Recalled.",
|
|
1764
1990
|
refreshAfter: true
|
|
@@ -2113,10 +2339,12 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
|
2113
2339
|
var import_node_crypto = require("crypto");
|
|
2114
2340
|
var import_automation = require("@objectstack/spec/automation");
|
|
2115
2341
|
var import_formula = require("@objectstack/formula");
|
|
2342
|
+
var import_metadata_core = require("@objectstack/metadata-core");
|
|
2116
2343
|
var import_types = require("@objectstack/types");
|
|
2117
2344
|
var import_identity = require("@objectstack/spec/identity");
|
|
2118
2345
|
var import_contracts3 = require("@objectstack/spec/contracts");
|
|
2119
2346
|
var import_data5 = require("@objectstack/spec/data");
|
|
2347
|
+
var import_system = require("@objectstack/spec/system");
|
|
2120
2348
|
var import_core = require("@objectstack/core");
|
|
2121
2349
|
|
|
2122
2350
|
// src/approver-org-scope.ts
|
|
@@ -2235,11 +2463,44 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
|
2235
2463
|
return userIds.filter((u) => canRead.has(u));
|
|
2236
2464
|
}
|
|
2237
2465
|
|
|
2466
|
+
// src/payload-redaction.ts
|
|
2467
|
+
function redactSnapshot(payload, readable) {
|
|
2468
|
+
if (readable === void 0) return { payload, redactedKeys: [] };
|
|
2469
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2470
|
+
return { payload, redactedKeys: [] };
|
|
2471
|
+
}
|
|
2472
|
+
const allowed = new Set(readable.map((f) => String(f)));
|
|
2473
|
+
const source = payload;
|
|
2474
|
+
const redactedKeys = [];
|
|
2475
|
+
const kept = {};
|
|
2476
|
+
for (const key of Object.keys(source)) {
|
|
2477
|
+
if (allowed.has(key)) kept[key] = source[key];
|
|
2478
|
+
else redactedKeys.push(key);
|
|
2479
|
+
}
|
|
2480
|
+
if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
|
|
2481
|
+
return { payload: kept, redactedKeys: redactedKeys.sort() };
|
|
2482
|
+
}
|
|
2483
|
+
async function resolveReadableSnapshotFields(security, objectName, context, logger) {
|
|
2484
|
+
if (!security || typeof security.getReadableFields !== "function") return void 0;
|
|
2485
|
+
const object = String(objectName ?? "").trim();
|
|
2486
|
+
if (!object) return void 0;
|
|
2487
|
+
try {
|
|
2488
|
+
return await security.getReadableFields(object, context);
|
|
2489
|
+
} catch (err) {
|
|
2490
|
+
logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
|
|
2491
|
+
object,
|
|
2492
|
+
error: err?.message ?? String(err)
|
|
2493
|
+
});
|
|
2494
|
+
return void 0;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2238
2498
|
// src/approval-service.ts
|
|
2239
2499
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
2240
2500
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
2241
2501
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
2242
2502
|
var SLA_ACTOR_ID = "system:sla";
|
|
2503
|
+
var ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse("2026-08-28T00:00:00Z");
|
|
2243
2504
|
var DEAD_RUN_ACTOR_ID = "system:dead-run";
|
|
2244
2505
|
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
2245
2506
|
"completed",
|
|
@@ -2248,13 +2509,43 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
|
2248
2509
|
"timed_out"
|
|
2249
2510
|
]);
|
|
2250
2511
|
var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
|
|
2512
|
+
function classifyStrandedRunState(run) {
|
|
2513
|
+
if (!run) return "missing";
|
|
2514
|
+
switch (run.status) {
|
|
2515
|
+
// The resume consumed the pause and a downstream node threw. Reported.
|
|
2516
|
+
case "failed":
|
|
2517
|
+
return "failed";
|
|
2518
|
+
// ── The negatives, each for its own reason ──────────────────────────────
|
|
2519
|
+
// The decision advanced the flow and the flow finished. Healthy.
|
|
2520
|
+
case "completed":
|
|
2521
|
+
return void 0;
|
|
2522
|
+
// Deliberately terminated by an operator (`cancelRun`, ADR-0044). The run
|
|
2523
|
+
// stopping is the intended outcome, exactly as `recalled` is on the request
|
|
2524
|
+
// side — reporting it would bury the real findings under expected ones.
|
|
2525
|
+
case "cancelled":
|
|
2526
|
+
return void 0;
|
|
2527
|
+
// The history's last row says `paused` while the suspension store says no
|
|
2528
|
+
// live pause. That is AMBIGUOUS, and the ambiguity is not resolvable from
|
|
2529
|
+
// one scan: a resume in flight right now has consumed the suspension and
|
|
2530
|
+
// not yet written its terminal row, and reads exactly like a process that
|
|
2531
|
+
// died in the same window. Condemning it would name every concurrently
|
|
2532
|
+
// resuming approval — so this stays SKIPPED, the conservative arm this
|
|
2533
|
+
// whole method is built on.
|
|
2534
|
+
case "paused":
|
|
2535
|
+
return void 0;
|
|
2536
|
+
default:
|
|
2537
|
+
return void 0;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2251
2540
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
2252
2541
|
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2542
|
+
var RECORD_DELETE_CANCEL_LIMIT = 200;
|
|
2253
2543
|
function actingUserId(context) {
|
|
2254
2544
|
const userId = context?.userId;
|
|
2255
2545
|
return typeof userId === "string" && userId ? userId : null;
|
|
2256
2546
|
}
|
|
2257
2547
|
var OOO_MAX_CHAIN = 8;
|
|
2548
|
+
var MEMBER_SCREEN_READ_LIMIT = 5e4;
|
|
2258
2549
|
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2259
2550
|
"team",
|
|
2260
2551
|
"department",
|
|
@@ -2309,6 +2600,10 @@ function rowFromRequest(row) {
|
|
|
2309
2600
|
submitter_id: row.submitter_id ?? void 0,
|
|
2310
2601
|
submitter_comment: row.submitter_comment ?? void 0,
|
|
2311
2602
|
status: row.status ?? "pending",
|
|
2603
|
+
// [#13568] Why a cancelled row was cancelled. Surfaced on every read for
|
|
2604
|
+
// the same reason `status` is: a client that can see the terminal state and
|
|
2605
|
+
// not the machine-readable cause is back to guessing from prose.
|
|
2606
|
+
cancel_reason: row.cancel_reason ?? void 0,
|
|
2312
2607
|
current_step: row.current_step ?? void 0,
|
|
2313
2608
|
current_step_index: row.current_step_index ?? void 0,
|
|
2314
2609
|
pending_approvers: csvSplit(row.pending_approvers),
|
|
@@ -2401,12 +2696,15 @@ function rowFromAction(row) {
|
|
|
2401
2696
|
var _ApprovalService = class _ApprovalService {
|
|
2402
2697
|
constructor(opts) {
|
|
2403
2698
|
this.engine = opts.engine;
|
|
2699
|
+
this.recordOrgResolver = (0, import_metadata_core.createRecordOrganizationResolver)(opts.engine);
|
|
2404
2700
|
this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
|
|
2405
2701
|
this.logger = opts.logger;
|
|
2406
2702
|
this.automation = opts.automation;
|
|
2407
2703
|
this.messaging = opts.messaging;
|
|
2408
2704
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2409
2705
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2706
|
+
this.fieldVisibility = opts.fieldVisibility;
|
|
2707
|
+
this.messageTranslator = opts.messageTranslator;
|
|
2410
2708
|
this.recordReaderVisibleObjects = new Set(
|
|
2411
2709
|
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2412
2710
|
);
|
|
@@ -2415,6 +2713,57 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2415
2713
|
attachTenancyPosture(provider) {
|
|
2416
2714
|
this.tenancyPosture = provider;
|
|
2417
2715
|
}
|
|
2716
|
+
/**
|
|
2717
|
+
* [#10749] Attach (or replace) the field-visibility authority the payload
|
|
2718
|
+
* redaction seam reads. Late-bound: plugin load order does not guarantee the
|
|
2719
|
+
* security service exists when this one is constructed.
|
|
2720
|
+
*/
|
|
2721
|
+
attachFieldVisibility(source) {
|
|
2722
|
+
this.fieldVisibility = source;
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* [#10749] Redact each row's payload snapshot down to the fields the READING
|
|
2726
|
+
* caller may see on that row's subject object.
|
|
2727
|
+
*
|
|
2728
|
+
* Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
|
|
2729
|
+
* load-bearing rather than incidental: `enrichRows` derives `payload_display`
|
|
2730
|
+
* (lookup foreign keys inside the snapshot resolved to referenced record
|
|
2731
|
+
* titles) and `payload_labels` (a label per snapshot key) by WALKING THE
|
|
2732
|
+
* SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
|
|
2733
|
+
* redact after and a restricted field's name, its authored label and the
|
|
2734
|
+
* title of the record it points at all still ship — the value would be gone
|
|
2735
|
+
* and the disclosure would not.
|
|
2736
|
+
*
|
|
2737
|
+
* Rows are grouped by subject object so one `getReadableFields` call covers a
|
|
2738
|
+
* whole page of same-object requests.
|
|
2739
|
+
*/
|
|
2740
|
+
async redactPayloads(rows, context) {
|
|
2741
|
+
const withPayload = rows.filter((r) => r?.payload != null);
|
|
2742
|
+
if (withPayload.length === 0) return;
|
|
2743
|
+
const byObject = /* @__PURE__ */ new Map();
|
|
2744
|
+
for (const r of withPayload) {
|
|
2745
|
+
const key = String(r.object_name ?? "");
|
|
2746
|
+
let list = byObject.get(key);
|
|
2747
|
+
if (!list) {
|
|
2748
|
+
list = [];
|
|
2749
|
+
byObject.set(key, list);
|
|
2750
|
+
}
|
|
2751
|
+
list.push(r);
|
|
2752
|
+
}
|
|
2753
|
+
for (const [object, group] of byObject) {
|
|
2754
|
+
const readable = await resolveReadableSnapshotFields(
|
|
2755
|
+
this.fieldVisibility,
|
|
2756
|
+
object,
|
|
2757
|
+
context,
|
|
2758
|
+
this.logger
|
|
2759
|
+
);
|
|
2760
|
+
if (readable === void 0) continue;
|
|
2761
|
+
for (const r of group) {
|
|
2762
|
+
const { payload } = redactSnapshot(r.payload, readable);
|
|
2763
|
+
r.payload = payload;
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2418
2767
|
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2419
2768
|
get orgScopeDeps() {
|
|
2420
2769
|
return {
|
|
@@ -2508,6 +2857,41 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2508
2857
|
const actorTenant = context.tenantId ?? context.organizationId ?? null;
|
|
2509
2858
|
return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
|
|
2510
2859
|
}
|
|
2860
|
+
/**
|
|
2861
|
+
* [#11993] The END USER's half of an approval refusal.
|
|
2862
|
+
*
|
|
2863
|
+
* `handleApprovalError` in `@objectstack/rest` maps this service's
|
|
2864
|
+
* `CODE: message` throws onto the wire by testing the prefix for the status
|
|
2865
|
+
* and then STRIPPING it (a leading run of `[A-Z_]` plus a colon and any
|
|
2866
|
+
* following whitespace), shipping what remains as the body's
|
|
2867
|
+
* human-readable `error` — which Console splices under its own localized
|
|
2868
|
+
* label ("撤回审批失败: …"). A hardcoded English reason therefore reaches an
|
|
2869
|
+
* operator in a fully Chinese deployment as a Chinese prefix glued onto an
|
|
2870
|
+
* English sentence they cannot act on.
|
|
2871
|
+
*
|
|
2872
|
+
* Rendered through the SHARED Operation Message Catalog
|
|
2873
|
+
* (`@objectstack/spec/system`), not a second mechanism: same
|
|
2874
|
+
* `errors.<key>` override address, same resolution ladder (deployment
|
|
2875
|
+
* override -> locale catalog -> `en` -> the key), same guarantee that a
|
|
2876
|
+
* misbehaving i18n service cannot turn a 403 into a 500. The catalog's
|
|
2877
|
+
* header names this seat explicitly; `plugin-security`'s
|
|
2878
|
+
* `userFacingDenialMessage` is the sibling consumer this mirrors.
|
|
2879
|
+
*
|
|
2880
|
+
* The CODE PREFIX stays on the message. It is not user copy — it is how the
|
|
2881
|
+
* REST layer derives the status and the ADR-0112 wire code, and it is
|
|
2882
|
+
* stripped before the sentence reaches a body. The developer's half moves
|
|
2883
|
+
* to the log, where the ids it names are legible to an operator and to
|
|
2884
|
+
* nobody else.
|
|
2885
|
+
*/
|
|
2886
|
+
userFacingRefusal(messageKey, context) {
|
|
2887
|
+
let translate;
|
|
2888
|
+
try {
|
|
2889
|
+
translate = this.messageTranslator?.();
|
|
2890
|
+
} catch {
|
|
2891
|
+
translate = void 0;
|
|
2892
|
+
}
|
|
2893
|
+
return (0, import_system.renderOperationMessage)({ messageKey }, { locale: context?.locale, translate });
|
|
2894
|
+
}
|
|
2511
2895
|
/**
|
|
2512
2896
|
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
2513
2897
|
*
|
|
@@ -2681,7 +3065,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2681
3065
|
}) : users;
|
|
2682
3066
|
try {
|
|
2683
3067
|
if (type === "team") {
|
|
2684
|
-
const users = await this.expandTeamUsers(String(a.value));
|
|
3068
|
+
const users = await this.expandTeamUsers(String(a.value), organizationId);
|
|
2685
3069
|
if (users.length) return users;
|
|
2686
3070
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
2687
3071
|
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
@@ -2695,7 +3079,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2695
3079
|
} else if (type === "manager" && record) {
|
|
2696
3080
|
const subject = record[a.value] ?? record.owner_id;
|
|
2697
3081
|
if (subject) {
|
|
2698
|
-
const mgr = await this.lookupManager(String(subject));
|
|
3082
|
+
const mgr = await this.lookupManager(String(subject), organizationId);
|
|
2699
3083
|
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
2700
3084
|
}
|
|
2701
3085
|
}
|
|
@@ -2792,7 +3176,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2792
3176
|
try {
|
|
2793
3177
|
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2794
3178
|
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2795
|
-
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
3179
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
|
|
2796
3180
|
else {
|
|
2797
3181
|
throw new Error(
|
|
2798
3182
|
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
@@ -2817,9 +3201,50 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2817
3201
|
}
|
|
2818
3202
|
return { slots, raw };
|
|
2819
3203
|
}
|
|
2820
|
-
/**
|
|
2821
|
-
|
|
3204
|
+
/**
|
|
3205
|
+
* Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
|
|
3206
|
+
*
|
|
3207
|
+
* Takes an organization for the reason every sibling expansion does
|
|
3208
|
+
* ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
|
|
3209
|
+
* {@link expandMembershipTierUsers}): an approver expansion answers "who, in
|
|
3210
|
+
* THIS organization". Before #10230 this one did not ask, and it was the last
|
|
3211
|
+
* expansion that did not — a `team` approver naming ANOTHER organization's
|
|
3212
|
+
* team routed that organization's people an approval over a record they are
|
|
3213
|
+
* not a tenant of.
|
|
3214
|
+
*
|
|
3215
|
+
* TWO screens run here, and they assert different things (#10230, #10547):
|
|
3216
|
+
*
|
|
3217
|
+
* 1. the TEAM must not provably belong to another organization
|
|
3218
|
+
* ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
|
|
3219
|
+
* `organization_id` outright
|
|
3220
|
+
* (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
|
|
3221
|
+
* team id transitively names exactly one organization and ONE row
|
|
3222
|
+
* answers the question;
|
|
3223
|
+
* 2. each expanded MEMBER must not provably hold membership only in other
|
|
3224
|
+
* organizations ({@link dropMembersProvablyOutsideOrg}) —
|
|
3225
|
+
* `sys_team_member` carries `team_id` and `user_id` and NO tenancy
|
|
3226
|
+
* column at all, so passing (1) says nothing whatever about the people
|
|
3227
|
+
* it lists.
|
|
3228
|
+
*
|
|
3229
|
+
* #10230 landed (1) alone and deferred (2) on purpose. What closed the
|
|
3230
|
+
* deferral is that (1) does not imply (2) even a little: a member removed
|
|
3231
|
+
* from the organization but left on the team, a team re-parented across
|
|
3232
|
+
* organizations (`/organization/update-team` accepts `organizationId` in its
|
|
3233
|
+
* partial body), or a `sys_team_member` row written by a seed rather than
|
|
3234
|
+
* through better-auth all produce a team that passes (1) carrying a user who
|
|
3235
|
+
* is provably a tenant of somewhere else. Measured on this tree, not read off
|
|
3236
|
+
* the schema — the probe is quoted in `team-member-org-screen.test.ts`.
|
|
3237
|
+
*
|
|
3238
|
+
* (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
|
|
3239
|
+
* further out, and it is asserted the same way: `sys_user` carries no tenancy
|
|
3240
|
+
* fact, so `sys_member` rows are the only evidence that a person is placed
|
|
3241
|
+
* anywhere. Like that screen, this one grants no reads and applies no read
|
|
3242
|
+
* screen to any approver type that lacks one today, so it decides nothing
|
|
3243
|
+
* #7497 (does approver routing imply record read visibility?) asks.
|
|
3244
|
+
*/
|
|
3245
|
+
async expandTeamUsers(teamId, organizationId) {
|
|
2822
3246
|
if (!teamId) return [];
|
|
3247
|
+
if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
|
|
2823
3248
|
let rows = [];
|
|
2824
3249
|
try {
|
|
2825
3250
|
rows = await this.engine.find("sys_team_member", {
|
|
@@ -2831,7 +3256,149 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2831
3256
|
} catch {
|
|
2832
3257
|
rows = [];
|
|
2833
3258
|
}
|
|
2834
|
-
|
|
3259
|
+
const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
3260
|
+
return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
|
|
3264
|
+
*
|
|
3265
|
+
* "Provably" carries the same posture the sibling screen states at length in
|
|
3266
|
+
* {@link managerIsProvablyOutsideOrg}, for the same reasons:
|
|
3267
|
+
*
|
|
3268
|
+
* - the team row carries an `organization_id` and it is not the request's
|
|
3269
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
|
|
3270
|
+
* - the row carries no `organization_id`, does not exist, or the read failed
|
|
3271
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3272
|
+
*
|
|
3273
|
+
* The `organization_id = null` limb is not timidity — it is the reading
|
|
3274
|
+
* {@link businessUnitOrgScope} settled on one screen below, for the identical
|
|
3275
|
+
* shape: null on a platform object means "owned by no organization", which is
|
|
3276
|
+
* what a seed writes because a seed cannot know the organization id the
|
|
3277
|
+
* runtime mints at boot. Treating null as "not mine" would delete every
|
|
3278
|
+
* seeded team approver at once — a larger behaviour change than the hole
|
|
3279
|
+
* being closed. Measured, and not hypothetically: this package's own
|
|
3280
|
+
* `team_ok` expansion fixture is exactly such a stack (it has
|
|
3281
|
+
* `sys_team_member` rows, a request carrying an organization, and no
|
|
3282
|
+
* `sys_team` row at all).
|
|
3283
|
+
*
|
|
3284
|
+
* Screening the TEAM before reading its members is also what keeps the cost
|
|
3285
|
+
* at one row: a team that fails the screen never fans out.
|
|
3286
|
+
*/
|
|
3287
|
+
async teamIsProvablyOutsideOrg(teamId, organizationId) {
|
|
3288
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3289
|
+
if (!requestOrg) return false;
|
|
3290
|
+
let rows = [];
|
|
3291
|
+
try {
|
|
3292
|
+
rows = await this.engine.find("sys_team", {
|
|
3293
|
+
where: { id: teamId },
|
|
3294
|
+
fields: ["id", "organization_id"],
|
|
3295
|
+
limit: 1,
|
|
3296
|
+
context: SYSTEM_CTX2
|
|
3297
|
+
});
|
|
3298
|
+
} catch {
|
|
3299
|
+
return false;
|
|
3300
|
+
}
|
|
3301
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
3302
|
+
const teamOrg = row?.organization_id ? String(row.organization_id) : "";
|
|
3303
|
+
if (!teamOrg) return false;
|
|
3304
|
+
if (teamOrg === requestOrg) return false;
|
|
3305
|
+
this.logger?.warn?.(
|
|
3306
|
+
`[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.`,
|
|
3307
|
+
{ teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
|
|
3308
|
+
);
|
|
3309
|
+
return true;
|
|
3310
|
+
}
|
|
3311
|
+
/**
|
|
3312
|
+
* Drop the expanded team members who are PROVABLY tenants of other
|
|
3313
|
+
* organizations and not of `organizationId`. (#10547)
|
|
3314
|
+
*
|
|
3315
|
+
* Returns the survivors, in the order they were expanded.
|
|
3316
|
+
*
|
|
3317
|
+
* Posture — identical to {@link managerIsProvablyOutsideOrg} and
|
|
3318
|
+
* {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
|
|
3319
|
+
* assertion about the same table:
|
|
3320
|
+
*
|
|
3321
|
+
* - membership rows exist for this user, none in `organizationId`
|
|
3322
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
|
|
3323
|
+
* - no membership rows at all for him, the read failed, or the request
|
|
3324
|
+
* carries no organization
|
|
3325
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3326
|
+
*
|
|
3327
|
+
* The absent limb is load-bearing rather than timid, and #3807 is the recorded
|
|
3328
|
+
* cost of getting it wrong: a stack that stamps an organization on requests
|
|
3329
|
+
* but never materializes `sys_member` rows would otherwise lose EVERY team
|
|
3330
|
+
* approver at once. This package's own `team_ok` expansion fixture and
|
|
3331
|
+
* #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
|
|
3332
|
+
* a request organization and no `sys_member` table at all — so the absent
|
|
3333
|
+
* limb is exercised by neighbours on every run of this suite.
|
|
3334
|
+
*
|
|
3335
|
+
* ONE read for the whole slate, never one per person: the expansion is capped
|
|
3336
|
+
* at 10000 members and a per-user query would turn a single team approver
|
|
3337
|
+
* into 10000 round trips.
|
|
3338
|
+
*
|
|
3339
|
+
* ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
|
|
3340
|
+
* the only evidence that a member IS a tenant here, so a result cut off at
|
|
3341
|
+
* the limit could be missing the very row that keeps a legitimate approver on
|
|
3342
|
+
* the slate — screening him out on missing evidence, which inverts the
|
|
3343
|
+
* posture into fail-CLOSED precisely where it must not. When the read comes
|
|
3344
|
+
* back at the cap it is treated as no evidence at all.
|
|
3345
|
+
*/
|
|
3346
|
+
async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
|
|
3347
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3348
|
+
if (!requestOrg || !userIds.length) return userIds;
|
|
3349
|
+
let rows = [];
|
|
3350
|
+
try {
|
|
3351
|
+
rows = await this.engine.find("sys_member", {
|
|
3352
|
+
where: { user_id: { $in: userIds } },
|
|
3353
|
+
fields: ["user_id", "organization_id"],
|
|
3354
|
+
limit: MEMBER_SCREEN_READ_LIMIT,
|
|
3355
|
+
context: SYSTEM_CTX2
|
|
3356
|
+
});
|
|
3357
|
+
} catch {
|
|
3358
|
+
return userIds;
|
|
3359
|
+
}
|
|
3360
|
+
if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
|
|
3361
|
+
this.logger?.warn?.(
|
|
3362
|
+
`[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.`,
|
|
3363
|
+
{ teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
|
|
3364
|
+
);
|
|
3365
|
+
return userIds;
|
|
3366
|
+
}
|
|
3367
|
+
const orgsByUser = /* @__PURE__ */ new Map();
|
|
3368
|
+
for (const r of rows ?? []) {
|
|
3369
|
+
const uid2 = String(r?.user_id ?? "");
|
|
3370
|
+
const org = String(r?.organization_id ?? "");
|
|
3371
|
+
if (!uid2 || !org) continue;
|
|
3372
|
+
const seen = orgsByUser.get(uid2);
|
|
3373
|
+
if (seen) seen.push(org);
|
|
3374
|
+
else orgsByUser.set(uid2, [org]);
|
|
3375
|
+
}
|
|
3376
|
+
const kept = [];
|
|
3377
|
+
const dropped = [];
|
|
3378
|
+
for (const uid2 of userIds) {
|
|
3379
|
+
const orgs = orgsByUser.get(uid2);
|
|
3380
|
+
if (!orgs?.length) {
|
|
3381
|
+
kept.push(uid2);
|
|
3382
|
+
continue;
|
|
3383
|
+
}
|
|
3384
|
+
if (orgs.includes(requestOrg)) {
|
|
3385
|
+
kept.push(uid2);
|
|
3386
|
+
continue;
|
|
3387
|
+
}
|
|
3388
|
+
dropped.push({ userId: uid2, organizationIds: orgs });
|
|
3389
|
+
}
|
|
3390
|
+
if (dropped.length) {
|
|
3391
|
+
this.logger?.warn?.(
|
|
3392
|
+
`[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.`,
|
|
3393
|
+
{
|
|
3394
|
+
teamId,
|
|
3395
|
+
requestOrganizationId: requestOrg,
|
|
3396
|
+
droppedUserIds: dropped.map((d) => d.userId),
|
|
3397
|
+
droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
|
|
3398
|
+
}
|
|
3399
|
+
);
|
|
3400
|
+
}
|
|
3401
|
+
return kept;
|
|
2835
3402
|
}
|
|
2836
3403
|
/**
|
|
2837
3404
|
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
@@ -3000,7 +3567,36 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3000
3567
|
}
|
|
3001
3568
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
3002
3569
|
}
|
|
3003
|
-
|
|
3570
|
+
/**
|
|
3571
|
+
* `sys_user.manager_id`, screened to the request's organization (#10153).
|
|
3572
|
+
*
|
|
3573
|
+
* Takes an organization argument for the same reason its siblings do
|
|
3574
|
+
* ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
|
|
3575
|
+
* approver expansion answers "who, in THIS organization". Before #10153 this
|
|
3576
|
+
* one did not ask, and it was the only expansion that did not — a
|
|
3577
|
+
* `manager_id` pointing at a person in another organization routed that
|
|
3578
|
+
* person an approval over a record they are not a tenant of.
|
|
3579
|
+
*
|
|
3580
|
+
* ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
|
|
3581
|
+
* filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
|
|
3582
|
+
* comment exists so the next reader does not conclude that #7497 (does
|
|
3583
|
+
* approver routing imply record read visibility?) was settled here. It was
|
|
3584
|
+
* not. Two facts make this the SIBLING treatment rather than a
|
|
3585
|
+
* read-visibility ruling:
|
|
3586
|
+
*
|
|
3587
|
+
* 1. Two of the three org-scoped expansions already screen on exactly this
|
|
3588
|
+
* column — `expandMembershipTierUsers` filters `sys_member.organization_id`
|
|
3589
|
+
* outright, and it is also the second limb of `expandPositionUsers`. So
|
|
3590
|
+
* `sys_member.organization_id` is already this file's answer to "which
|
|
3591
|
+
* organization is this person in", independent of what they may read.
|
|
3592
|
+
* 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
|
|
3593
|
+
* table, so a membership row is the only tenancy fact that exists for a
|
|
3594
|
+
* user — there is no other read this screen could have been written with.
|
|
3595
|
+
*
|
|
3596
|
+
* This change grants no reads and applies no read screen to any type that
|
|
3597
|
+
* lacks one today, so it decides nothing #7497 asks.
|
|
3598
|
+
*/
|
|
3599
|
+
async lookupManager(userId, organizationId) {
|
|
3004
3600
|
try {
|
|
3005
3601
|
const rows = await this.engine.find("sys_user", {
|
|
3006
3602
|
where: { id: userId },
|
|
@@ -3009,11 +3605,64 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3009
3605
|
context: SYSTEM_CTX2
|
|
3010
3606
|
});
|
|
3011
3607
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
3012
|
-
|
|
3608
|
+
const managerId = row?.manager_id ? String(row.manager_id) : null;
|
|
3609
|
+
if (!managerId) return null;
|
|
3610
|
+
if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
|
|
3611
|
+
return managerId;
|
|
3013
3612
|
} catch {
|
|
3014
3613
|
return null;
|
|
3015
3614
|
}
|
|
3016
3615
|
}
|
|
3616
|
+
/**
|
|
3617
|
+
* Is `managerId` PROVABLY a member of other organizations and not of
|
|
3618
|
+
* `organizationId`? (#10153)
|
|
3619
|
+
*
|
|
3620
|
+
* "Provably" is the whole shape of this screen, and it is deliberate rather
|
|
3621
|
+
* than a weaker version of "must prove membership":
|
|
3622
|
+
*
|
|
3623
|
+
* - membership rows exist for this user, none in the request's org
|
|
3624
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
|
|
3625
|
+
* - no membership rows at all, or the read failed
|
|
3626
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3627
|
+
*
|
|
3628
|
+
* The fail-open half is not timidity, it is this file's ruled posture on
|
|
3629
|
+
* addressing paths, stated twice already: {@link filterApproversWhoCanRead}
|
|
3630
|
+
* refuses to empty a live slate on an infrastructure hiccup, and
|
|
3631
|
+
* {@link expandPositionUsers} carries "a step routing to nobody is worse than
|
|
3632
|
+
* one routing to a lapsed holder". It is also load-bearing in practice — a
|
|
3633
|
+
* stack that stamps an organization on its requests but does not materialize
|
|
3634
|
+
* `sys_member` rows would otherwise lose every manager approver at once,
|
|
3635
|
+
* which is a bigger behaviour change than the hole being closed. Measured:
|
|
3636
|
+
* this repo's own `type:manager` out-of-office fixture is such a stack.
|
|
3637
|
+
*
|
|
3638
|
+
* Screening the MANAGER only, before OOO delegation, is deliberate too: the
|
|
3639
|
+
* delegate arrives from `sys_approval_delegation`, whose rows already carry
|
|
3640
|
+
* (and are already filtered by) an `organization_id` in
|
|
3641
|
+
* {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
|
|
3642
|
+
*/
|
|
3643
|
+
async managerIsProvablyOutsideOrg(managerId, organizationId) {
|
|
3644
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3645
|
+
if (!requestOrg) return false;
|
|
3646
|
+
let rows = [];
|
|
3647
|
+
try {
|
|
3648
|
+
rows = await this.engine.find("sys_member", {
|
|
3649
|
+
where: { user_id: managerId },
|
|
3650
|
+
fields: ["user_id", "organization_id"],
|
|
3651
|
+
limit: 1e3,
|
|
3652
|
+
context: SYSTEM_CTX2
|
|
3653
|
+
});
|
|
3654
|
+
} catch {
|
|
3655
|
+
return false;
|
|
3656
|
+
}
|
|
3657
|
+
const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
|
|
3658
|
+
if (!orgs.length) return false;
|
|
3659
|
+
if (orgs.includes(requestOrg)) return false;
|
|
3660
|
+
this.logger?.warn?.(
|
|
3661
|
+
`[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.`,
|
|
3662
|
+
{ managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
|
|
3663
|
+
);
|
|
3664
|
+
return true;
|
|
3665
|
+
}
|
|
3017
3666
|
/**
|
|
3018
3667
|
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
3019
3668
|
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
@@ -3183,11 +3832,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3183
3832
|
const substitutions = [];
|
|
3184
3833
|
const groups = {};
|
|
3185
3834
|
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
3835
|
+
const subjectOrg = this.recordOrgResolver.organizationOf(input.object, liveRecord, input.record);
|
|
3836
|
+
const requestOrg = subjectOrg ?? ctxOrg;
|
|
3186
3837
|
const resolvedFrom = {};
|
|
3187
3838
|
const approvers = await this.expandApprovers(
|
|
3188
3839
|
{ approvers: input.config.approvers },
|
|
3189
3840
|
liveRecord,
|
|
3190
|
-
|
|
3841
|
+
requestOrg,
|
|
3191
3842
|
{
|
|
3192
3843
|
now: nowDate.getTime(),
|
|
3193
3844
|
substitutions,
|
|
@@ -3251,16 +3902,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3251
3902
|
flow_run_id: input.runId,
|
|
3252
3903
|
flow_node_id: input.nodeId,
|
|
3253
3904
|
node_config_json: JSON.stringify(configSnapshot),
|
|
3254
|
-
organization_id:
|
|
3905
|
+
organization_id: requestOrg,
|
|
3255
3906
|
created_at: now,
|
|
3256
3907
|
updated_at: now
|
|
3257
3908
|
};
|
|
3258
3909
|
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
3259
|
-
await this.syncApproverIndex(id, approvers,
|
|
3910
|
+
await this.syncApproverIndex(id, approvers, requestOrg, now);
|
|
3260
3911
|
await this.engine.insert("sys_approval_action", {
|
|
3261
3912
|
id: uid("aact"),
|
|
3262
3913
|
request_id: id,
|
|
3263
|
-
organization_id:
|
|
3914
|
+
organization_id: requestOrg,
|
|
3264
3915
|
step_name: input.nodeId,
|
|
3265
3916
|
step_index: 0,
|
|
3266
3917
|
action: "submit",
|
|
@@ -3272,7 +3923,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3272
3923
|
await this.engine.insert("sys_approval_action", {
|
|
3273
3924
|
id: uid("aact"),
|
|
3274
3925
|
request_id: id,
|
|
3275
|
-
organization_id:
|
|
3926
|
+
organization_id: requestOrg,
|
|
3276
3927
|
step_name: input.nodeId,
|
|
3277
3928
|
step_index: 0,
|
|
3278
3929
|
action: "ooo_substitute",
|
|
@@ -3714,6 +4365,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3714
4365
|
* is then paused at the revise-window node (no reject edge), so it is
|
|
3715
4366
|
* terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
|
|
3716
4367
|
* than resumed.
|
|
4368
|
+
*
|
|
4369
|
+
* The #3424 privileged override reaches a PENDING request only (#12775,
|
|
4370
|
+
* maintainer ruling 2026-09-02). On `returned` an override actor is refused
|
|
4371
|
+
* exactly as any other non-submitter: the gate is spelled as `attachViewers`
|
|
4372
|
+
* computes `viewer.can_override`, so the gate, the
|
|
4373
|
+
* {@link ApprovalService.isOverrideActor} doc block and the viewer flag agree
|
|
4374
|
+
* at one point.
|
|
3717
4375
|
*/
|
|
3718
4376
|
async recall(requestId, input, context) {
|
|
3719
4377
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
@@ -3729,8 +4387,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3729
4387
|
if (raw.status !== "pending" && !inReviseWindow) {
|
|
3730
4388
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
3731
4389
|
}
|
|
3732
|
-
|
|
3733
|
-
|
|
4390
|
+
const overrideAdmits = raw.status === "pending" && this.isOverrideActor(context, raw.organization_id ?? null);
|
|
4391
|
+
if (!overrideAdmits && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
4392
|
+
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)`;
|
|
4393
|
+
this.logger?.warn?.(developerMessage, {
|
|
4394
|
+
request: requestId,
|
|
4395
|
+
actor: actorId,
|
|
4396
|
+
submitter: String(raw.submitter_id),
|
|
4397
|
+
status: raw.status
|
|
4398
|
+
});
|
|
4399
|
+
throw new Error(
|
|
4400
|
+
`FORBIDDEN: ${this.userFacingRefusal("approval_recall_not_submitter", context)}`
|
|
4401
|
+
);
|
|
3734
4402
|
}
|
|
3735
4403
|
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
3736
4404
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
@@ -3806,6 +4474,127 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3806
4474
|
const fresh = await this.readBackRequest(requestId, context);
|
|
3807
4475
|
return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
|
|
3808
4476
|
}
|
|
4477
|
+
// ── Record-delete lifecycle linkage (#13568) ─────────────────
|
|
4478
|
+
/**
|
|
4479
|
+
* Void every `pending` request about a record that has just been DELETED
|
|
4480
|
+
* (#13568, maintainer ruling 2026-08-31 「同意」).
|
|
4481
|
+
*
|
|
4482
|
+
* ## What the ruling asked for, and what each half costs
|
|
4483
|
+
*
|
|
4484
|
+
* status → `cancelled`, a machine-readable `cancel_reason`, the row KEPT
|
|
4485
|
+
* for audit, and the request out of the pending count and the inbox's
|
|
4486
|
+
* default view.
|
|
4487
|
+
*
|
|
4488
|
+
* The last clause needs both writes below, not just the status one: the
|
|
4489
|
+
* inbox's "My Pending" tab pages the request table by `status`, but the
|
|
4490
|
+
* approver filter resolves through the normalized `sys_approval_approver`
|
|
4491
|
+
* index (#1745), which is keyed on nothing but `request_id`. Leaving the
|
|
4492
|
+
* index rows behind would keep the request in `approverRequestIds`' answer
|
|
4493
|
+
* for every approver forever — so {@link syncApproverIndex} clears them, the
|
|
4494
|
+
* same call every other exit from `pending` makes.
|
|
4495
|
+
*
|
|
4496
|
+
* ## Three things this deliberately does NOT do
|
|
4497
|
+
*
|
|
4498
|
+
* 1. **No status mirror-back.** Every other terminal transition calls
|
|
4499
|
+
* {@link mirrorStatusField} to write the outcome onto the subject record's
|
|
4500
|
+
* `approvalStatusField`. Here the subject record is precisely what no
|
|
4501
|
+
* longer exists, so that write is guaranteed to fail — it is the very
|
|
4502
|
+
* `update_record(...) failed: Record ... not found` this card's own
|
|
4503
|
+
* forensics recorded on the reject door. Skipped by construction, not by a
|
|
4504
|
+
* swallowed error.
|
|
4505
|
+
*
|
|
4506
|
+
* 2. **No flow resume, and no run cancel.** A cancellation is a status write
|
|
4507
|
+
* plus a reason; it is not a decision, so there is no branch to resume
|
|
4508
|
+
* down and no downstream node that should run. The suspended run this
|
|
4509
|
+
* request gated is reported (below) and left alone — what becomes of an
|
|
4510
|
+
* approval run whose request was voided is a lifecycle question that
|
|
4511
|
+
* belongs to the automation service, not to a delete hook, and answering
|
|
4512
|
+
* it here by guessing would be exactly the kind of consumer-side
|
|
4513
|
+
* accommodation PD #12 refuses.
|
|
4514
|
+
*
|
|
4515
|
+
* 3. **No refusal of the delete.** The delete has already landed by the time
|
|
4516
|
+
* this runs, and the "forbid delete while an approval is pending"
|
|
4517
|
+
* direction was VETOED in the same ruling (`lockRecord` already blocks the
|
|
4518
|
+
* edit; blocking the delete too locks an author onto a record they cannot
|
|
4519
|
+
* fix). So nothing here throws — a failure degrades to a warning and the
|
|
4520
|
+
* stale row, which is the pre-existing state, never to a failed delete.
|
|
4521
|
+
*
|
|
4522
|
+
* Terminal rows are untouched: the `where` below names `status: 'pending'`,
|
|
4523
|
+
* so an `approved` / `rejected` / `recalled` / `returned` request about the
|
|
4524
|
+
* same record keeps its recorded outcome. That is the ruling's second half —
|
|
4525
|
+
* history is kept; only the presentation of its dead record reference is
|
|
4526
|
+
* someone else's card.
|
|
4527
|
+
*
|
|
4528
|
+
* @returns the request ids cancelled and the suspended run ids left behind.
|
|
4529
|
+
*/
|
|
4530
|
+
async cancelForDeletedRecord(objectName, recordId) {
|
|
4531
|
+
const empty = { cancelled: [], suspendedRuns: [] };
|
|
4532
|
+
const object = String(objectName ?? "").trim();
|
|
4533
|
+
const record = String(recordId ?? "").trim();
|
|
4534
|
+
if (!object || !record) return empty;
|
|
4535
|
+
let pending;
|
|
4536
|
+
try {
|
|
4537
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
4538
|
+
where: { object_name: object, record_id: record, status: "pending" },
|
|
4539
|
+
limit: RECORD_DELETE_CANCEL_LIMIT,
|
|
4540
|
+
context: SYSTEM_CTX2
|
|
4541
|
+
});
|
|
4542
|
+
pending = Array.isArray(rows) ? rows : [];
|
|
4543
|
+
} catch (err) {
|
|
4544
|
+
this.logger?.warn?.(
|
|
4545
|
+
"[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",
|
|
4546
|
+
{ object, record, error: err?.message ?? String(err) }
|
|
4547
|
+
);
|
|
4548
|
+
return empty;
|
|
4549
|
+
}
|
|
4550
|
+
if (pending.length === 0) return empty;
|
|
4551
|
+
const now = this.clock.now().toISOString();
|
|
4552
|
+
const cancelled = [];
|
|
4553
|
+
const suspendedRuns = [];
|
|
4554
|
+
for (const raw of pending) {
|
|
4555
|
+
const requestId = String(raw?.id ?? "");
|
|
4556
|
+
if (!requestId) continue;
|
|
4557
|
+
const org = raw?.organization_id ?? null;
|
|
4558
|
+
const nodeId = raw?.flow_node_id ?? raw?.current_step ?? null;
|
|
4559
|
+
try {
|
|
4560
|
+
await this.engine.insert("sys_approval_action", {
|
|
4561
|
+
id: uid("aact"),
|
|
4562
|
+
request_id: requestId,
|
|
4563
|
+
organization_id: org,
|
|
4564
|
+
step_name: nodeId,
|
|
4565
|
+
step_index: 0,
|
|
4566
|
+
action: "cancel",
|
|
4567
|
+
actor_id: null,
|
|
4568
|
+
comment: `Auto-cancelled: the ${object} record '${record}' this request is about was deleted`,
|
|
4569
|
+
created_at: now
|
|
4570
|
+
}, { context: SYSTEM_CTX2 });
|
|
4571
|
+
await this.engine.update("sys_approval_request", {
|
|
4572
|
+
id: requestId,
|
|
4573
|
+
status: "cancelled",
|
|
4574
|
+
cancel_reason: "record_deleted",
|
|
4575
|
+
pending_approvers: null,
|
|
4576
|
+
completed_at: now,
|
|
4577
|
+
updated_at: now
|
|
4578
|
+
}, { context: SYSTEM_CTX2 });
|
|
4579
|
+
await this.syncApproverIndex(requestId, [], org, now);
|
|
4580
|
+
cancelled.push(requestId);
|
|
4581
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4582
|
+
if (runId) suspendedRuns.push(runId);
|
|
4583
|
+
} catch (err) {
|
|
4584
|
+
this.logger?.warn?.(
|
|
4585
|
+
"[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",
|
|
4586
|
+
{ object, record, request: requestId, error: err?.message ?? String(err) }
|
|
4587
|
+
);
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
if (suspendedRuns.length) {
|
|
4591
|
+
this.logger?.warn?.(
|
|
4592
|
+
"[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",
|
|
4593
|
+
{ object, record, requests: cancelled, runs: suspendedRuns }
|
|
4594
|
+
);
|
|
4595
|
+
}
|
|
4596
|
+
return { cancelled, suspendedRuns };
|
|
4597
|
+
}
|
|
3809
4598
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
3810
4599
|
/**
|
|
3811
4600
|
* ADR-0044 send back for revision. Finalises the pending request as
|
|
@@ -4436,7 +5225,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4436
5225
|
* `escalation.timeoutHours` and whose deadline has passed is escalated
|
|
4437
5226
|
* **at most once, ever** — the `escalate` audit row is the idempotency
|
|
4438
5227
|
* marker, written before any mutation (audit-first, like reassign). One
|
|
4439
|
-
* bad row never stops the sweep.
|
|
5228
|
+
* bad row never stops the sweep. A block that explicitly declares
|
|
5229
|
+
* `enabled: false` is skipped (#12278 — the declared switch is enforced;
|
|
5230
|
+
* pre-flip snapshots ride the legacy window on
|
|
5231
|
+
* {@link ESCALATION_ENABLED_FLIP_CUTOFF_MS}).
|
|
4440
5232
|
*/
|
|
4441
5233
|
async runEscalations() {
|
|
4442
5234
|
let rows = [];
|
|
@@ -4458,6 +5250,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4458
5250
|
const cfg = parseJson(raw.node_config_json, void 0);
|
|
4459
5251
|
const esc2 = cfg?.escalation;
|
|
4460
5252
|
if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
|
|
5253
|
+
if (esc2.enabled === false && Date.parse(raw.created_at ?? "") >= ESCALATION_ENABLED_FLIP_CUTOFF_MS) continue;
|
|
4461
5254
|
const due = slaDueAt(raw.created_at, cfg);
|
|
4462
5255
|
if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
|
|
4463
5256
|
const prior = await this.engine.find("sys_approval_action", {
|
|
@@ -4538,9 +5331,35 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4538
5331
|
* live pause exists. It THROWS when the store cannot be read, and that
|
|
4539
5332
|
* case is SKIPPED, never counted as dead: an unreadable store means
|
|
4540
5333
|
* "unknown", and a storage outage must not be published as a lost run.
|
|
4541
|
-
* - `getRun(runId)
|
|
4542
|
-
* prefixed rows in `sys_automation_run`). A run
|
|
4543
|
-
* not stranded; a request whose run neither waits
|
|
5334
|
+
* - `classifyStrandedRunState` over `getRun(runId)` — the run's own
|
|
5335
|
+
* history row (the `run_` prefixed rows in `sys_automation_run`). A run
|
|
5336
|
+
* that merely finished is not stranded; a request whose run neither waits
|
|
5337
|
+
* nor completed is.
|
|
5338
|
+
*
|
|
5339
|
+
* **The second oracle was widened (#13909), and this is the whole point of
|
|
5340
|
+
* that card's first slice.** It used to be `if (terminal) continue` — the
|
|
5341
|
+
* existence of ANY history row ended the check, on the reading "the run ran to
|
|
5342
|
+
* a terminal state, it is not dangling". That is true of a run that COMPLETED
|
|
5343
|
+
* and false of one that FAILED: the engine consumes a suspension *before*
|
|
5344
|
+
* running the downstream nodes (`AutomationEngine.resumeInternal` calls
|
|
5345
|
+
* `forgetSuspendedRun(run, 'resumed')` and only then `traverseNext`), so a
|
|
5346
|
+
* downstream node that merely threw threw with the pause already gone, and the
|
|
5347
|
+
* catch arm wrote a terminal `failed` row. The decision is durable, the
|
|
5348
|
+
* continuation stopped half-way, `resume` answers `RUN_NOT_FOUND` and
|
|
5349
|
+
* `cancelRun` is a no-op — and the terminal row this oracle used to read as
|
|
5350
|
+
* health is written BY the very failure that stranded it. So this inspection
|
|
5351
|
+
* reported `0` for the one shape an operator most needs to see.
|
|
5352
|
+
*
|
|
5353
|
+
* ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
|
|
5354
|
+
* and `paused` are each still skipped, for reasons named one at a time in
|
|
5355
|
+
* `classifyStrandedRunState`, and an unrecognised status is skipped too.
|
|
5356
|
+
* What the widening buys is that a `failed` run is now reported with
|
|
5357
|
+
* `runState: 'failed'` instead of counted as healthy.
|
|
5358
|
+
*
|
|
5359
|
+
* ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
|
|
5360
|
+
* deployment; it is not itself a census, and it says nothing about this
|
|
5361
|
+
* repository. How many runs are already in this state can only be answered
|
|
5362
|
+
* against a real deployment's tables — see the card.
|
|
4544
5363
|
*
|
|
4545
5364
|
* **Reports; never rewrites.** No status is changed and no run is cancelled.
|
|
4546
5365
|
* The decision genuinely happened — a human approved or rejected — and
|
|
@@ -4598,7 +5417,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4598
5417
|
});
|
|
4599
5418
|
continue;
|
|
4600
5419
|
}
|
|
4601
|
-
|
|
5420
|
+
const runState = classifyStrandedRunState(terminal);
|
|
5421
|
+
if (!runState) continue;
|
|
4602
5422
|
const config = parseJson(
|
|
4603
5423
|
raw.node_config_json,
|
|
4604
5424
|
{ approvers: [], behavior: "first_response" }
|
|
@@ -4621,6 +5441,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4621
5441
|
requestId: String(raw.id),
|
|
4622
5442
|
status: raw.status,
|
|
4623
5443
|
runId,
|
|
5444
|
+
runState,
|
|
4624
5445
|
flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
|
|
4625
5446
|
nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
|
|
4626
5447
|
objectName: raw.object_name,
|
|
@@ -4632,11 +5453,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
4632
5453
|
});
|
|
4633
5454
|
}
|
|
4634
5455
|
if (stranded.length || undetermined) {
|
|
4635
|
-
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run
|
|
5456
|
+
this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run unrecoverable)", {
|
|
4636
5457
|
scanned: rows.length,
|
|
4637
5458
|
stranded: stranded.length,
|
|
4638
5459
|
undetermined,
|
|
4639
|
-
|
|
5460
|
+
runMissing: stranded.filter((s) => s.runState === "missing").length,
|
|
5461
|
+
runFailed: stranded.filter((s) => s.runState === "failed").length,
|
|
5462
|
+
requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
|
|
4640
5463
|
});
|
|
4641
5464
|
}
|
|
4642
5465
|
return { scanned: rows.length, stranded, undetermined };
|
|
@@ -5139,8 +5962,70 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5139
5962
|
return { requests: desired.size, inserted, deleted };
|
|
5140
5963
|
}
|
|
5141
5964
|
// ── Read API ─────────────────────────────────────────────────
|
|
5965
|
+
/**
|
|
5966
|
+
* [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
|
|
5967
|
+
* for THIS caller over THIS query's scope?
|
|
5968
|
+
*
|
|
5969
|
+
* `payload_json` is the one searched column whose contents the serve path
|
|
5970
|
+
* masks per reader (`redactPayloads`, #10749). A predicate over it is
|
|
5971
|
+
* evaluated by the driver against the column AT REST — unmasked, before
|
|
5972
|
+
* anything is served — so for a caller whose view of the snapshot is masked,
|
|
5973
|
+
* row membership answers questions about contents that caller may not read.
|
|
5974
|
+
* The other four arms are columns of `sys_approval_request` itself, which
|
|
5975
|
+
* every caller who can see the row reads whole; they are untouched.
|
|
5976
|
+
*
|
|
5977
|
+
* ## The invariant this method exists to hold
|
|
5978
|
+
*
|
|
5979
|
+
* "This caller's view is masked" is read from **the same authority and the
|
|
5980
|
+
* same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
|
|
5981
|
+
* asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
|
|
5982
|
+
* notion of "redacted" — comparing the readable set against the object's
|
|
5983
|
+
* schema, say — would be a fresh source of drift, and drift between the
|
|
5984
|
+
* serve rule and the filter rule IS the defect this closes, reconstituted one
|
|
5985
|
+
* layer down. So the only "not masked" answer accepted here is the one serve
|
|
5986
|
+
* itself acts on: `undefined`, the seam's documented do-not-narrow branch.
|
|
5987
|
+
* When the seam holds a concrete list the mask is IN FORCE, whether or not it
|
|
5988
|
+
* happens to remove a key from any particular row — a row-dependent question
|
|
5989
|
+
* no predicate can answer before rows exist.
|
|
5990
|
+
*
|
|
5991
|
+
* ## The two predicate-time cases
|
|
5992
|
+
*
|
|
5993
|
+
* Redaction is decided per ROW (each row names its own subject object, hence
|
|
5994
|
+
* its own readable set), but a filter is built before any row exists:
|
|
5995
|
+
*
|
|
5996
|
+
* - **authority absent** — `resolveReadableSnapshotFields` answers
|
|
5997
|
+
* `undefined` for EVERY object, so serve hands over every snapshot whole.
|
|
5998
|
+
* Keeping the arm leaks nothing serve does not already hand over, and this
|
|
5999
|
+
* is the shape every deployment that has not wired the security plugin
|
|
6000
|
+
* gets: search is byte-for-byte unchanged. Checked first, and that order
|
|
6001
|
+
* is load-bearing — see the object-scope note below.
|
|
6002
|
+
* - **authority wired** — the readable set is per object, so the arm is
|
|
6003
|
+
* admissible only for a scope of exactly one KNOWN object. With
|
|
6004
|
+
* `filter.object` present that object is known at predicate time and the
|
|
6005
|
+
* seam is asked about it directly. Absent, the query spans every object
|
|
6006
|
+
* and there is nothing sound to ask, so the arm is dropped.
|
|
6007
|
+
*
|
|
6008
|
+
* Dropping an arm is strictly NARROWING: it never refuses a query and never
|
|
6009
|
+
* widens what comes back, so the fail-closed direction is cheap here and is
|
|
6010
|
+
* taken rather than reaching for a per-object predicate machine to avoid it.
|
|
6011
|
+
* Refusing the query outright would be the louder behaviour change, and is
|
|
6012
|
+
* deliberately not what this does.
|
|
6013
|
+
*/
|
|
6014
|
+
async freeTextMayMatchSnapshot(objectInScope, context) {
|
|
6015
|
+
const authority = this.fieldVisibility;
|
|
6016
|
+
if (!authority || typeof authority.getReadableFields !== "function") return true;
|
|
6017
|
+
const object = String(objectInScope ?? "").trim();
|
|
6018
|
+
if (!object) return false;
|
|
6019
|
+
const readable = await resolveReadableSnapshotFields(
|
|
6020
|
+
authority,
|
|
6021
|
+
object,
|
|
6022
|
+
context,
|
|
6023
|
+
this.logger
|
|
6024
|
+
);
|
|
6025
|
+
return readable === void 0;
|
|
6026
|
+
}
|
|
5142
6027
|
/** Filter type accepted by {@link listRequests} / {@link countRequests}. */
|
|
5143
|
-
buildRequestWhere(filter, context) {
|
|
6028
|
+
async buildRequestWhere(filter, context) {
|
|
5144
6029
|
const f = {};
|
|
5145
6030
|
if (filter?.object) f.object_name = filter.object;
|
|
5146
6031
|
if (filter?.recordId) f.record_id = filter.recordId;
|
|
@@ -5149,13 +6034,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5149
6034
|
if (tenantOrg) f.organization_id = tenantOrg;
|
|
5150
6035
|
const q = filter?.q?.trim();
|
|
5151
6036
|
if (q) {
|
|
5152
|
-
|
|
6037
|
+
const arms = [
|
|
5153
6038
|
{ process_name: { $contains: q } },
|
|
5154
6039
|
{ object_name: { $contains: q } },
|
|
5155
6040
|
{ record_id: { $contains: q } },
|
|
5156
|
-
{ submitter_id: { $contains: q } }
|
|
5157
|
-
{ payload_json: { $contains: q } }
|
|
6041
|
+
{ submitter_id: { $contains: q } }
|
|
5158
6042
|
];
|
|
6043
|
+
if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
|
|
6044
|
+
arms.push({ payload_json: { $contains: q } });
|
|
6045
|
+
}
|
|
6046
|
+
f.$or = arms;
|
|
5159
6047
|
}
|
|
5160
6048
|
if (Array.isArray(filter?.status)) {
|
|
5161
6049
|
const statuses = filter.status.filter(Boolean);
|
|
@@ -5358,7 +6246,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5358
6246
|
return true;
|
|
5359
6247
|
}
|
|
5360
6248
|
async listRequests(filter, context) {
|
|
5361
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
6249
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5362
6250
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5363
6251
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5364
6252
|
if (ids) {
|
|
@@ -5382,12 +6270,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5382
6270
|
}
|
|
5383
6271
|
const rows = await this.engine.find("sys_approval_request", findOpts);
|
|
5384
6272
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
6273
|
+
await this.redactPayloads(list, context);
|
|
5385
6274
|
await this.enrichRows(list);
|
|
5386
6275
|
this.attachViewers(list, context);
|
|
5387
6276
|
return list;
|
|
5388
6277
|
}
|
|
5389
6278
|
async countRequests(filter, context) {
|
|
5390
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
6279
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5391
6280
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5392
6281
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5393
6282
|
if (ids) {
|
|
@@ -5424,9 +6313,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5424
6313
|
* flow-driven resume, a service-to-service call), turning a successful write
|
|
5425
6314
|
* into a `null` result. Gating belongs on the read API, not on an
|
|
5426
6315
|
* operation's own return value.
|
|
6316
|
+
*
|
|
6317
|
+
* [#12769] The read still narrows by the CALLER's organization
|
|
6318
|
+
* (`loadRequest` — a deliberate tenancy wall, untouched here), and an
|
|
6319
|
+
* org-less request row is invisible inside an org-scoped caller's narrowing.
|
|
6320
|
+
* Org-less rows arise by construction on every schedule / time-relative /
|
|
6321
|
+
* api trigger run (#10131; #9132 pinned that behaviour rather than repairing
|
|
6322
|
+
* it), so this is a live state, not an edge case: the write IS recorded and
|
|
6323
|
+
* the echo cannot be built. It used to escape as `null` behind the callers'
|
|
6324
|
+
* `fresh!` non-null assertions — a well-formed 200 whose declared-non-null
|
|
6325
|
+
* `request` was `null` on the wire. Now it refuses loudly instead, which
|
|
6326
|
+
* keeps every declared result type true: a result that cannot be built is
|
|
6327
|
+
* never returned. Callers must NOT catch this to fall back to `null` —
|
|
6328
|
+
* that would re-open the type-lie one level up.
|
|
5427
6329
|
*/
|
|
5428
6330
|
async readBackRequest(requestId, context) {
|
|
5429
|
-
|
|
6331
|
+
const fresh = await this.loadRequest(requestId, context, false);
|
|
6332
|
+
if (!fresh) {
|
|
6333
|
+
throw new Error(
|
|
6334
|
+
`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.`
|
|
6335
|
+
);
|
|
6336
|
+
}
|
|
6337
|
+
return fresh;
|
|
5430
6338
|
}
|
|
5431
6339
|
async getRequest(requestId, context) {
|
|
5432
6340
|
return this.loadRequest(requestId, context, true);
|
|
@@ -5450,6 +6358,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5450
6358
|
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
5451
6359
|
}
|
|
5452
6360
|
const row = rowFromRequest(rows[0]);
|
|
6361
|
+
await this.redactPayloads([row], context);
|
|
5453
6362
|
await this.enrichRows([row]);
|
|
5454
6363
|
await this.attachFlowSteps(row);
|
|
5455
6364
|
await this.attachDecisionProgress(row, rows[0]);
|
|
@@ -5917,6 +6826,41 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
5917
6826
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
5918
6827
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
5919
6828
|
}
|
|
6829
|
+
var APPROVALS_OWN_OBJECTS = [
|
|
6830
|
+
"sys_approval_request",
|
|
6831
|
+
"sys_approval_action",
|
|
6832
|
+
"sys_approval_approver",
|
|
6833
|
+
"sys_approval_token",
|
|
6834
|
+
"sys_approval_delegation"
|
|
6835
|
+
];
|
|
6836
|
+
function bindRecordDeleteCancelHook(engine, service, logger) {
|
|
6837
|
+
engine.registerHook("afterDelete", async (ctx) => {
|
|
6838
|
+
const object = ctx?.object ?? ctx?.objectName;
|
|
6839
|
+
if (!object) return;
|
|
6840
|
+
const raw = ctx?.previous?.id ?? ctx?.input?.id;
|
|
6841
|
+
const recordId = raw == null ? "" : String(raw);
|
|
6842
|
+
if (!recordId) {
|
|
6843
|
+
logger?.warn?.(
|
|
6844
|
+
"[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",
|
|
6845
|
+
{ object }
|
|
6846
|
+
);
|
|
6847
|
+
return;
|
|
6848
|
+
}
|
|
6849
|
+
try {
|
|
6850
|
+
await service.cancelForDeletedRecord(String(object), recordId);
|
|
6851
|
+
} catch (err) {
|
|
6852
|
+
logger?.warn?.(
|
|
6853
|
+
"[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",
|
|
6854
|
+
{ object, record: recordId, error: err?.message ?? String(err) }
|
|
6855
|
+
);
|
|
6856
|
+
}
|
|
6857
|
+
}, {
|
|
6858
|
+
excludeObjects: [...APPROVALS_OWN_OBJECTS],
|
|
6859
|
+
packageId: APPROVALS_HOOK_PACKAGE,
|
|
6860
|
+
priority: 50
|
|
6861
|
+
});
|
|
6862
|
+
logger?.info?.("[approvals] record-delete cancel hook bound");
|
|
6863
|
+
}
|
|
5920
6864
|
var DELEGATION_OBJECT = "sys_approval_delegation";
|
|
5921
6865
|
function bindDelegationWriteGuard(engine, logger) {
|
|
5922
6866
|
const makeGuard = (isInsert) => async (ctx) => {
|
|
@@ -5953,6 +6897,52 @@ function unbindAllHooks(engine) {
|
|
|
5953
6897
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
5954
6898
|
}
|
|
5955
6899
|
|
|
6900
|
+
// src/payload-redaction-middleware.ts
|
|
6901
|
+
var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
|
|
6902
|
+
function parseSnapshot(raw) {
|
|
6903
|
+
if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
|
|
6904
|
+
try {
|
|
6905
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
6906
|
+
} catch {
|
|
6907
|
+
return { ok: false, value: void 0 };
|
|
6908
|
+
}
|
|
6909
|
+
}
|
|
6910
|
+
async function redactRowsInPlace(rows, security, context, logger) {
|
|
6911
|
+
const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
|
|
6912
|
+
if (list.length === 0) return;
|
|
6913
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6914
|
+
for (const row of list) {
|
|
6915
|
+
if (!row || typeof row !== "object") continue;
|
|
6916
|
+
const raw = row.payload_json;
|
|
6917
|
+
const parsed = parseSnapshot(raw);
|
|
6918
|
+
if (!parsed.ok) continue;
|
|
6919
|
+
const object = String(row.object_name ?? "").trim();
|
|
6920
|
+
if (!object) continue;
|
|
6921
|
+
if (!cache.has(object)) {
|
|
6922
|
+
cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
|
|
6923
|
+
}
|
|
6924
|
+
const readable = cache.get(object);
|
|
6925
|
+
if (readable === void 0) continue;
|
|
6926
|
+
const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
|
|
6927
|
+
if (redactedKeys.length === 0) continue;
|
|
6928
|
+
row.payload_json = JSON.stringify(payload);
|
|
6929
|
+
}
|
|
6930
|
+
}
|
|
6931
|
+
function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
|
|
6932
|
+
engine.registerMiddleware(async (opCtx, next) => {
|
|
6933
|
+
await next();
|
|
6934
|
+
if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
|
|
6935
|
+
if (opCtx?.context?.isSystem) return;
|
|
6936
|
+
try {
|
|
6937
|
+
await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
|
|
6938
|
+
} catch (err) {
|
|
6939
|
+
logger?.warn?.("[approvals] snapshot redaction middleware failed", {
|
|
6940
|
+
error: err?.message ?? String(err)
|
|
6941
|
+
});
|
|
6942
|
+
}
|
|
6943
|
+
}, { object: APPROVAL_REQUEST_OBJECT });
|
|
6944
|
+
}
|
|
6945
|
+
|
|
5956
6946
|
// src/approval-node.ts
|
|
5957
6947
|
var import_automation3 = require("@objectstack/spec/automation");
|
|
5958
6948
|
|
|
@@ -6214,13 +7204,46 @@ var ApprovalsServicePlugin = class {
|
|
|
6214
7204
|
} catch {
|
|
6215
7205
|
return void 0;
|
|
6216
7206
|
}
|
|
7207
|
+
},
|
|
7208
|
+
// [#11993] Deployment override lookup for user-facing refusal copy.
|
|
7209
|
+
// Resolved LAZILY for the reason the two providers above are: the i18n
|
|
7210
|
+
// service is contributed by another plugin (ADR-0029 D8) and may start
|
|
7211
|
+
// after this one. Without it the service still renders the built-in
|
|
7212
|
+
// catalog in the caller's locale; with it, a deployment's own
|
|
7213
|
+
// `translation` for `errors.approval_recall_not_submitter` wins — the
|
|
7214
|
+
// override address the catalog documents, made real for this emitter.
|
|
7215
|
+
messageTranslator: () => {
|
|
7216
|
+
try {
|
|
7217
|
+
const i18n = ctx.getService("i18n");
|
|
7218
|
+
const t = i18n?.t;
|
|
7219
|
+
if (typeof t !== "function") return void 0;
|
|
7220
|
+
return (key, locale, params) => t.call(i18n, key, locale, params);
|
|
7221
|
+
} catch {
|
|
7222
|
+
return void 0;
|
|
7223
|
+
}
|
|
7224
|
+
}
|
|
7225
|
+
});
|
|
7226
|
+
const fieldVisibility = () => {
|
|
7227
|
+
try {
|
|
7228
|
+
const sec = ctx.getService("security");
|
|
7229
|
+
return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
|
|
7230
|
+
} catch {
|
|
7231
|
+
return void 0;
|
|
7232
|
+
}
|
|
7233
|
+
};
|
|
7234
|
+
this.service.attachFieldVisibility({
|
|
7235
|
+
getReadableFields: (object, context) => {
|
|
7236
|
+
const sec = fieldVisibility();
|
|
7237
|
+
return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
|
|
6217
7238
|
}
|
|
6218
7239
|
});
|
|
6219
7240
|
if (!this.options.disableAutoHooks) {
|
|
6220
7241
|
try {
|
|
6221
7242
|
unbindAllHooks(engine);
|
|
6222
7243
|
bindApprovalLockHook(engine, ctx.logger);
|
|
7244
|
+
bindRecordDeleteCancelHook(engine, this.service, ctx.logger);
|
|
6223
7245
|
bindDelegationWriteGuard(engine, ctx.logger);
|
|
7246
|
+
bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
|
|
6224
7247
|
} catch (err) {
|
|
6225
7248
|
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
6226
7249
|
}
|
|
@@ -6260,6 +7283,7 @@ var ApprovalsServicePlugin = class {
|
|
|
6260
7283
|
}
|
|
6261
7284
|
};
|
|
6262
7285
|
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
7286
|
+
this.jobService = jobs;
|
|
6263
7287
|
this.escalationJobScheduled = true;
|
|
6264
7288
|
void sweep().catch((err) => {
|
|
6265
7289
|
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
@@ -6346,14 +7370,32 @@ var ApprovalsServicePlugin = class {
|
|
|
6346
7370
|
);
|
|
6347
7371
|
}
|
|
6348
7372
|
}
|
|
6349
|
-
|
|
7373
|
+
/**
|
|
7374
|
+
* The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
|
|
7375
|
+
* ONLY teardown entry point `ObjectKernel.performShutdown()` and
|
|
7376
|
+
* `LiteKernel.destroy()` invoke.
|
|
7377
|
+
*
|
|
7378
|
+
* [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
|
|
7379
|
+
* `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
|
|
7380
|
+
* past this plugin at shutdown: the SLA escalation job stayed scheduled and
|
|
7381
|
+
* this plugin's ObjectQL hooks stayed bound to an engine the kernel had
|
|
7382
|
+
* finished with. `start()` IS on the interface, so the pair read as symmetric
|
|
7383
|
+
* in review — that asymmetry is what let the same shape survive in six
|
|
7384
|
+
* packages at once.
|
|
7385
|
+
*
|
|
7386
|
+
* This member owns no timer of its own (the escalation clock belongs to
|
|
7387
|
+
* `service-job`), so it never cost a merge-queue eviction the way the
|
|
7388
|
+
* `plugin-reports` / `service-messaging` members did (#9371). The class is
|
|
7389
|
+
* the same one either way: a teardown the kernel does not reach.
|
|
7390
|
+
*/
|
|
7391
|
+
async destroy() {
|
|
6350
7392
|
if (this.escalationJobScheduled) {
|
|
6351
7393
|
try {
|
|
6352
|
-
|
|
6353
|
-
await jobs?.cancel?.(ESCALATION_JOB_NAME);
|
|
7394
|
+
await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
|
|
6354
7395
|
} catch {
|
|
6355
7396
|
}
|
|
6356
7397
|
this.escalationJobScheduled = false;
|
|
7398
|
+
this.jobService = void 0;
|
|
6357
7399
|
}
|
|
6358
7400
|
if (this.engine) {
|
|
6359
7401
|
try {
|
|
@@ -6362,6 +7404,17 @@ var ApprovalsServicePlugin = class {
|
|
|
6362
7404
|
}
|
|
6363
7405
|
}
|
|
6364
7406
|
}
|
|
7407
|
+
/**
|
|
7408
|
+
* Retained alias for {@link destroy}. Kept because it is public API of an
|
|
7409
|
+
* exported class, and removing it would break an embedder who learned to call
|
|
7410
|
+
* it directly precisely BECAUSE the kernel never did. The parameter is now
|
|
7411
|
+
* optional and ignored: `destroy()` takes no context, so teardown uses the
|
|
7412
|
+
* job service captured when the escalation clock was wired. Prefer kernel
|
|
7413
|
+
* shutdown; direct callers keep working unchanged.
|
|
7414
|
+
*/
|
|
7415
|
+
async stop(_ctx) {
|
|
7416
|
+
await this.destroy();
|
|
7417
|
+
}
|
|
6365
7418
|
};
|
|
6366
7419
|
// Annotate the CommonJS export names for ESM import in node:
|
|
6367
7420
|
0 && (module.exports = {
|