w-web-perm 1.1.13 → 1.1.15

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.
Files changed (114) hide show
  1. package/README.md +98 -7
  2. package/dist/css/{app.f09c7433.css → app.f24c5e50.css} +1 -1
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.307bef4f.js +1 -0
  5. package/dist/js/app.307bef4f.js +1 -0
  6. package/dist/js/{chunk-vendors.ece5a361.js → chunk-vendors-legacy.6cc4d11e.js} +1 -1
  7. package/dist/js/{chunk-vendors-legacy.ece5a361.js → chunk-vendors.6cc4d11e.js} +1 -1
  8. package/dist/w-web-perm.umd.js +2 -2
  9. package/dist/w-web-perm.umd.js.map +1 -1
  10. package/docs/WWebPerm.html +15 -7
  11. package/docs/global.html +11733 -5078
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +154 -345
  14. package/docs/server_adminCheck.mjs.html +92 -0
  15. package/docs/server_cascadeWrite.mjs.html +5 -26
  16. package/docs/server_dataChannel.mjs.html +4 -4
  17. package/docs/server_keyCheck.mjs.html +150 -23
  18. package/docs/server_lockSave.mjs.html +2 -2
  19. package/docs/server_maskLog.mjs.html +2 -2
  20. package/docs/server_procCore.mjs.html +261 -65
  21. package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
  22. package/docs/server_syncCore.mjs.html +533 -0
  23. package/docs/server_syncKeep.mjs.html +27 -20
  24. package/docs/server_tools_authRefsAudit.mjs.html +30 -10
  25. package/docs/server_tools_authRefsClean.mjs.html +2 -2
  26. package/docs/server_tools_fromTools.mjs.html +2 -2
  27. package/docs/server_tools_isMain.mjs.html +2 -2
  28. package/docs/server_tools_nameDupReport.mjs.html +40 -7
  29. package/docs/server_validateRows.mjs.html +2 -2
  30. package/docs/server_wrapInjected.mjs.html +2 -2
  31. package/docs/src_fetchJson.mjs.html +2 -2
  32. package/docs/src_getPermUserInfor.mjs.html +175 -0
  33. package/docs/src_js_applySaved.mjs.html +128 -0
  34. package/docs/src_js_cellEditing.mjs.html +163 -0
  35. package/docs/src_js_cvKpToArr.mjs.html +2 -2
  36. package/docs/src_js_cvKpToObj.mjs.html +2 -2
  37. package/docs/src_js_draftSync.mjs.html +83 -7
  38. package/docs/src_js_eachOwn.mjs.html +2 -2
  39. package/docs/src_js_editorRefRows.mjs.html +2 -2
  40. package/docs/src_js_externalRows.mjs.html +34 -10
  41. package/docs/src_js_externalUi.mjs.html +4 -8
  42. package/docs/src_js_findRefRows.mjs.html +2 -2
  43. package/docs/src_js_findTouchedDup.mjs.html +2 -2
  44. package/docs/src_js_fmtLossyMsg.mjs.html +2 -2
  45. package/docs/src_js_fromAudit.mjs.html +2 -2
  46. package/docs/src_js_getAddImpact.mjs.html +2 -2
  47. package/docs/src_js_getAuthWarn.mjs.html +2 -2
  48. package/docs/src_js_getCascadePreview.mjs.html +2 -2
  49. package/docs/src_js_getDelRefCount.mjs.html +2 -2
  50. package/docs/src_js_getEffMode.mjs.html +2 -2
  51. package/docs/src_js_getKpLossy.mjs.html +2 -2
  52. package/docs/src_js_getNameErrs.mjs.html +2 -2
  53. package/docs/src_js_getOwn.mjs.html +2 -2
  54. package/docs/src_js_getPropKey.mjs.html +2 -2
  55. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  56. package/docs/src_js_isAuthMode.mjs.html +2 -2
  57. package/docs/src_js_joinNames.mjs.html +2 -2
  58. package/docs/src_js_legendFit.mjs.html +2 -2
  59. package/docs/src_js_pairRowsToSnapshot.mjs.html +2 -2
  60. package/docs/src_js_refAudit.mjs.html +2 -2
  61. package/docs/src_js_refCascade.mjs.html +2 -2
  62. package/docs/src_js_refIdentity.mjs.html +500 -0
  63. package/docs/src_js_relDialogSend.mjs.html +2 -2
  64. package/docs/src_js_rowDigest.mjs.html +75 -145
  65. package/docs/src_plugins_dataReloadGuard.mjs.html +2 -2
  66. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  67. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  68. package/g_provideGrups.mjs +1 -1
  69. package/g_providePemis.mjs +1 -1
  70. package/g_provideTargets.mjs +1 -1
  71. package/g_provideUsers.mjs +1 -1
  72. package/package.json +2 -2
  73. package/server/WWebPerm.mjs +152 -343
  74. package/server/adminCheck.mjs +20 -0
  75. package/server/cascadeWrite.mjs +3 -24
  76. package/server/dataChannel.mjs +2 -2
  77. package/server/keyCheck.mjs +148 -21
  78. package/server/procCore.mjs +259 -63
  79. package/server/procLang.mjs +78 -4
  80. package/server/provideTabs.mjs +6 -4
  81. package/server/syncCore.mjs +461 -0
  82. package/server/syncKeep.mjs +25 -18
  83. package/server/tools/authRefsAudit.mjs +28 -8
  84. package/server/tools/nameDupReport.mjs +38 -5
  85. package/src/App.vue +6 -1
  86. package/src/components/LayoutContentGrups.vue +57 -8
  87. package/src/components/LayoutContentPemis.vue +57 -8
  88. package/src/components/LayoutContentTargets.vue +38 -7
  89. package/src/components/LayoutContentUsers.vue +57 -8
  90. package/src/components/LayoutState.vue +12 -0
  91. package/src/components/VeGrupBlngUsers.vue +128 -20
  92. package/src/components/VePemiBlngGrups.vue +128 -20
  93. package/src/getPermUserInfor.mjs +10 -0
  94. package/src/js/applySaved.mjs +56 -0
  95. package/src/js/cellEditing.mjs +91 -0
  96. package/src/js/draftSync.mjs +81 -5
  97. package/src/js/externalRows.mjs +32 -8
  98. package/src/js/externalUi.mjs +2 -6
  99. package/src/js/refIdentity.mjs +428 -0
  100. package/src/js/rowDigest.mjs +73 -143
  101. package/src/plugins/mUI.mjs +5 -0
  102. package/src/schema/contracts/grups.mjs +8 -0
  103. package/src/schema/contracts/index.mjs +17 -0
  104. package/src/schema/contracts/pemis.mjs +8 -0
  105. package/src/schema/contracts/targets.mjs +8 -0
  106. package/src/schema/contracts/users.mjs +13 -0
  107. package/src/schema/tables/grups.mjs +4 -0
  108. package/src/schema/tables/pemis.mjs +4 -0
  109. package/src/schema/tables/targets.mjs +4 -0
  110. package/src/schema/tables/users.mjs +4 -0
  111. package/src/store/mutations.mjs +1 -1
  112. package/srv.mjs +43 -0
  113. package/dist/js/app-legacy.8af87052.js +0 -1
  114. package/dist/js/app.8af87052.js +0 -1
@@ -21,38 +21,41 @@ import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
21
21
  import fsIsFile from 'wsemi/src/fsIsFile.mjs'
22
22
  import replace from 'wsemi/src/replace.mjs'
23
23
  import strdelleft from 'wsemi/src/strdelleft.mjs'
24
- import ltdtmapping from 'wsemi/src/ltdtmapping.mjs'
25
- import pmKeyMutex from 'wsemi/src/pmKeyMutex.mjs'
26
24
  import cacheSt from 'wsemi/src/cacheSt.mjs'
27
25
  import WServHapiServer from 'w-serv-hapi/src/WServHapiServer.mjs'
28
26
  import WServOrm from 'w-serv-orm/src/WServOrm.mjs'
29
27
  import checkOptRemoved from 'w-serv-orm/src/checkOptRemoved.mjs'
30
28
  import ds from '../src/schema/index.mjs'
31
29
  import { getUserRules } from '../src/plugins/mShare.mjs'
32
- import procCore, { toOutKey, isRowObj } from './procCore.mjs'
30
+ import procCore, { toOutKey, pickClientOpt } from './procCore.mjs'
31
+ import syncCore from './syncCore.mjs'
33
32
  import createLockSave from './lockSave.mjs'
34
33
  import procLang from './procLang.mjs'
35
34
  import srLogInit from './srLog.mjs'
36
35
  import procStaInfor from './procStaInfor.mjs'
37
36
  import { maskTok, maskQuery, maskUrl } from './maskLog.mjs'
38
37
  import wrapInjected from './wrapInjected.mjs'
39
- import validateRowsBeforeWrite from './validateRows.mjs'
40
- import planRefChanges, { REF_OF, diffRefTargets } from '../src/js/refCascade.mjs'
41
- import { findDupIndex, findKeyOtherFrom, withTabLocks, mapKeyOf, checkMapKeyBatch, pickUniqueUser } from './keyCheck.mjs'
42
- import { checkCascadePlan, writeWithCascade, buildSyncRowsNew } from './cascadeWrite.mjs'
38
+ import { pickUniqueUser, findLoginUsers, mapKeyOf } from './keyCheck.mjs'
43
39
  import { createAuthorizeExec, wrapOperOrm } from './dataChannel.mjs'
44
- import { markHasKey, keepAssignOnMissing, keepAssignByEntity, countIdRenumber } from './syncKeep.mjs'
40
+ import isAdminRow from './adminCheck.mjs'
45
41
 
46
42
 
47
43
  /**
48
44
  * 權限伺服器
49
45
  *
46
+ * 身分驗證責任(dspec/使用者身分驗證原則.md 第三節, 使用方套件標準註解): 框架(w-converhp、w-serv-hapi、w-serv-webdata、w-serv-orm)不驗證 token, 一律由本套件負責.
47
+ * 連線閘(verifyConn)與執行身分(getUserIdByToken)皆須完整驗證(token 有效、本系統使用者存在且啟用、部署方授權); hapi apis 路由各自驗證; 函數層級另檢查角色或成員; 公開入口只限列名者.
48
+ * 本套件之落實(perm-run01 起「權限後台與資料通道只限系統管理員」, 規則帳本 R-ADMIN-ONLY-01): 資料通道之連線閘(標頭 token, 所有入口: main 之 RPC/表 select/系統函數, 上傳下載各 apiType)與執行身分(請求本體 __sysToken__)、後台登入檢查(/api/getUserByToken)皆經 getAndVerifyAdminUser
49
+ * (外部 getUserByToken → perm users 存在且 isActive='y' → 部署方 verifyClientUser → perm users 表之列 isAdmin='y'; perm 以自身 users 表為權威, 不採 token 宣稱之 isAdmin), 任一關不過函數不執行(一般使用者即使部署方放行亦被拒, 回 userNotAdmin);
50
+ * 一般使用者(isAdmin='n')只能經 /api/getPerm 以 token 查本人權限樹; 改變狀態之 kpFunExt 函數(updateTargets/updatePemis/updateGrups/updateUsers)另保留 perm users 表中有效且 isAdmin='y' 之縱深檢查(requireAdmin), 不符回 userNotAdmin、零寫入;
51
+ * hapi apis 路由: getUserByToken(只限管理員)/getPerm(任何通過 verifyClientUser 之有效使用者, 回本人權限樹)驗 client 使用者, getPermUserInfor/syncAndReplaceTabs 驗 app 使用者(verifyAppUser); 無公開入口(前端靜態檔除外).
52
+ *
50
53
  * @class
51
54
  * @param {Function} WOrm 輸入資料庫ORM函數
52
55
  * @param {String} url 輸入資料庫連線字串,例如w-orm-lmdb為'./db',或w-orm-mongodb為'mongodb://username:password@$127.0.0.1:27017'
53
56
  * @param {String} db 輸入資料庫名稱字串
54
57
  * @param {Function} getUserByToken 輸入處理函數,函數會傳入使用者token,通過此函數處理後並回傳使用者資訊物件,並至少須提供'id'、'email'、'name'、'isAdmin'欄位,且'isAdmin'限輸入'y'或'n'。注意: token 回傳之'isAdmin'僅供識別,權限系統一律以自身users表該使用者之'isAdmin'欄位值為權威,不會被 token 宣稱之值複寫。函數拋錯或reject時視同查無使用者(回自家錯誤key,錯誤原文不回前端、不入log,只於srLog記warn)
55
- * @param {Function} verifyClientUser 輸入驗證瀏覽使用者身份之處理函數,函數會傳入使用者資訊物件(perm users 表之列)與來源字串from,通過此函數識別後回傳布林值(可為Promise),允許使用者回傳true,反之回傳false。from 值域: 'getUserByToken'(登入)、'getPerm'(api/getPerm)、以及資料通道之 apiType 'main'(RPC 與 ORM 執行)、'upload-controller'、'upload-slice'、'download-get-filename'、'download-get-file'、'download';資料通道與登入採同一授權強度(反查 perm users 且 isActive='y' 後再經本函數);函數拋錯或reject時視同回傳false
58
+ * @param {Function} verifyClientUser 輸入驗證瀏覽使用者身份之處理函數,函數會傳入使用者資訊物件(perm users 表之列)與來源字串from,通過此函數識別後回傳布林值(可為Promise),允許使用者回傳true,反之回傳false。from 值域: 'getUserByToken'(登入)、'getPerm'(api/getPerm)、以及資料通道之 apiType 'main'(RPC 與 ORM 執行; 連線閘驗標頭 token、執行身分〔getUserIdByToken〕驗請求本體 __sysToken__, 兩者各經本函數一次, 本體 token 與標頭 token 不同時兩個使用者各自驗證)、'upload-controller'、'upload-slice'、'download-get-filename'、'download-get-file'、'download';資料通道與登入採同一授權強度(反查 perm users 且 isActive='y' 後再經本函數);函數拋錯或reject時視同回傳false。本函數收到的是該列之淺層副本,改寫其欄位不影響本套件後續採用之執行身分與角色判定。本函數通過後,本套件另要求資料通道與後台登入(from 為 'getUserByToken'、'main'、'upload-controller' 等)限 perm users 表中 isAdmin 為 'y' 者(一般使用者即使本函數放行亦被拒,回 userNotAdmin),故本函數不必再以 from 區分管理員與一般使用者,但可再收緊;一般使用者查本人權限樹之 /api/getPerm(from 為 'getPerm')仍會呼叫本函數,勿回傳 user.isAdmin === 'y' 而擋掉他們
56
59
  * @param {Function} verifyAppUser 輸入驗證應用程序使用者身份之處理函數,函數會傳入使用者資訊物件,通過此函數識別後回傳布林值,允許使用者回傳true,反之回傳false;函數拋錯或reject時視同回傳false
57
60
  * @param {Object} [opt={}] 輸入設定物件,預設{}
58
61
  * @param {Integer} [opt.serverPort=11006] 輸入伺服器通訊port,預設11006。註:原opt.useCheckUser、opt.getUserById、opt.useExcludeWhenNotAdmin已隨w-serv-orm 1.1.9移除,給予undefined、null、false以外之值時建構即拋錯(錯誤訊息出自w-serv-orm之checkOptRemoved,並記srLog);資料通道之使用者授權由verifyConn(verifyClientUser)負責、表操作只開放select,刪除該三鍵即可,無須替代設定
@@ -61,7 +64,7 @@ import { markHasKey, keepAssignOnMissing, keepAssignByEntity, countIdRenumber }
61
64
  * @param {String} [opt.webLogo=''] 輸入站台logo字串,採base64格式,預設''
62
65
  * @param {String} [opt.subfolder=''] 輸入站台所在子目錄字串,提供站台位於內網採反向代理進行服務時,故需支援位於子目錄情形,預設''
63
66
  * @param {String} [opt.urlRedirect=''] 輸入錯誤時自動轉址字串,提供站台例如無法登入或驗證失敗時須自動轉址,預設''
64
- * @param {String} [opt.mappingBy='id'] 輸入外部系統識別使用者token後所提供之資料物件,與權限系統之使用者資料物件,兩者間查找之對應欄位,可選'id'、'email'、'name',未給或非此三者時為'id',預設'id'。對應鍵須能唯一指出使用者:mappingBy為'email'或'name'時,外部同步(syncAndReplaceTabs)users之有效(isActive為'y')列,該欄位須為非空字串且與本批及其他from之有效列皆不重複(違規整批拒收invalidRows);登入查找以對應鍵於權限系統users命中多於1筆有效使用者時一律拒絕(ambiguousUserFromPerm),不取首筆;mappingBy為'id'(預設)時不另檢查
67
+ * @param {String} [opt.mappingBy='id'] 輸入外部系統識別使用者token後所提供之資料物件,與權限系統之使用者資料物件,兩者間查找之對應欄位,可選'id'、'email'、'name',未給或非此三者時為'id',預設'id'。對應鍵須能唯一指出使用者:mappingBy為'email'或'name'時,外部同步(syncAndReplaceTabs)users之有效(isActive為'y')列,該欄位須為非空字串且與本批及其他from之有效列皆不重複(違規整批拒收invalidRows);登入查找以對應鍵於權限系統users命中多於1筆有效使用者時一律拒絕(ambiguousUserFromPerm),不取首筆;mappingBy為'id'(預設)時,後台列(from為空)以其id對應外部token使用者之id,外部同步列(from非空)則以其來源鍵srcKey對應(外部列之id為本系統配發之內部主鍵,不是登入識別;升版前同步寫入、尚無srcKey之舊外部列在首次匯入補上srcKey前暫以id對應),並防止撞值(登入識別值須跨全表唯一,不論isActive、含他來源外部列;不同外部系統之使用者id碰撞常見而非同一人,已停用者之token不得對到他來源之有效列):外部同步(syncAndReplaceTabs)批次內任一列之srcKey等於他來源任一既有列之登入識別值(後台列id、他來源外部列srcKey)時整批拒收(loginIdCollision,零寫入,請部署方改用可唯一對應之鍵,如以email對應並將mappingBy設為'email')、後台存檔(updateUsers)新增或改變之後台列id撞到任一外部列srcKey時整次拒收(loginIdCollision,零變更),登入時若命中多於1筆(不論有效與否)則拒絕(ambiguousUserFromPerm)、恰命中1筆但已停用視同查無(cannotGetUserFromPerm);mappingBy為'id'時getPermUserInfor之userId亦依此對應解析(外部列傳其srcKey、後台列傳id,不是內部id;命中多筆拒絕ambiguousUserFromPerm),'email'/'name'時userId仍為perm內部id;升版前既有之跨來源重複srcKey歷史資料不阻擋存檔,但登入一律拒絕,請依README Upgrade Notes盤點處置
65
68
  * @param {Boolean} [opt.cascadeRename=true] 輸入群組、權限改名時是否連帶更新引用它之授權字串(users.cgrups、grups.cpemis),預設true。改名以主鍵id判定(同id異名),後台整表儲存與外部同步(同一from內)皆適用,於同一次寫入內改寫並可能寫入外部來源之users列之cgrups;給false則不連帶(引用成為懸空,視同引用查無對象而收嚴並於頁面提示)。群組、權限、標的被刪除時之引用連帶清理(users.cgrups、grups.cpemis、pemis.crules)不受此設定影響
66
69
  * @param {String} [opt.lockExternalRows='y'] 輸入後台是否不得改外部列(from為非空字串,即經syncAndReplaceTabs由外部來源同步之列),可選'y'或'n',預設'y'。'y'時伺服端強制:後台新增列from須為空字串、外部列不可刪除、不可改from、身分/結構欄(指派欄與順序以外之欄位)不可改,違規整次拒收(saveFromNotAllowed/saveExternalRowChanged);授權指派欄(users.cgrups、grups.cpemis、pemis.crules)後台可寫,但須附載入基準(用戶端自動附帶),無基準者拒收(saveBaselineRequired);外部列之order由外部擁有、後台儲存不重編。給'n'回到舊行為(僅保留from須為字串之檢查)。升版後既有庫中from為非空字串之列即視為外部列而唯讀,請先以g_fixFromType.mjs/g_purgeFrom.mjs盤點(見README Upgrade Notes)
67
70
  * @param {String} [opt.logFd='./logs'] 輸入log紀錄儲存位置字串,預設'./logs'
@@ -125,7 +128,9 @@ import { markHasKey, keepAssignOnMissing, keepAssignByEntity, countIdRenumber }
125
128
  * console.log('verifyClientUser/user', user)
126
129
  * // return false //測試無法登入
127
130
  * console.log('於生產環境時得加入限制瀏覽器使用者身份機制')
128
- * return user.isAdmin === 'y' //測試僅系統管理者使用
131
+ * //本套件於此函數通過後,另要求資料通道與後台登入限系統管理員(perm users 表之 isAdmin 為 'y'),一般使用者仍可經 /api/getPerm(from 為 'getPerm')以 token 查本人權限樹,
132
+ * //故此處不可回傳 user.isAdmin === 'y'(會擋掉一般使用者之 /api/getPerm); 僅需放行有效之瀏覽使用者,或依 from 與自家政策再收緊(例如依來源網段或系統別拒絕)
133
+ * return true
129
134
  * }
130
135
  *
131
136
  * let verifyAppUser = (user, from) => {
@@ -318,34 +323,26 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
318
323
  let { woItems, procOrm } = wp
319
324
 
320
325
 
321
- //pmKeyMutex: per-key in-memory mutex, 同 key 序列化、不同 key 並行.
322
- //perm 為「整表批次 diff」寫入, key 粒度為整表: tabWriteKey(keyTable)(= 'tabWrite:' + keyTable), 外部同步與後台整表寫入共用同鍵, 防同表並行 race (lost update) 與唯一性檢查被交錯略過 (R-WRITE-LOCK-01).
323
- let kmx = pmKeyMutex()
326
+ //lockTabs, guardWrite: w-serv-orm 1.1.17 的每表一鍵表鎖與原子防護(多人同時編輯規格第 6、7 節; 規則帳本 R-LOCK-CORE-01), 取自 WServOrm 回傳物件——**唯一**的鎖管理器.
327
+ //perm 為「整表批次 diff」寫入, 鎖粒度為整表: 後台整表寫入(procCore.updateTabItems 之 guardWrite)、外部同步(syncAndReplaceTabs)與引用連帶之引用端寫入皆取同一把表鎖, 防同表並行 race (lost update) 與唯一性檢查被交錯略過 (R-WRITE-LOCK-01).
328
+ //不再自建 pmKeyMutex/鎖鍵(原 tabWriteKey 已移除); 鎖不可重入, 多表依階層巢狀取鎖見 keyCheck.withTabLocks(R-WRITE-LOCK-02)
329
+ let lockTabs = wp.withTabLocks
330
+ let guardWrite = wp.guardWrite
324
331
 
325
332
 
326
- //syncWarns: 外部同步「同一 from 同名異 id」(疑似 id 重編)之提示登錄(記憶體, 重啟即清; 主控定案: 不加開關, 記 warn log 並使頁面可提示):
327
- //以「表名+來源」為鍵, 最近一次同步有此情形者登錄筆數與時間, 已無此情形者清除; 只含表名、來源名稱、筆數、時間, 不含個資
328
- let kpSyncWarn = new Map()
329
- let setSyncWarn = (keyTable, from, n) => {
330
- let k = `${keyTable}\u0000${from}`
331
- if (n > 0) {
332
- kpSyncWarn.set(k, { keyTable, from, n, time: new Date().toISOString() })
333
- }
334
- else {
335
- kpSyncWarn.delete(k)
336
- }
337
- }
338
- let getSyncWarns = () => [...kpSyncWarn.values()]
333
+ //sc: 外部匯入(POST /syncAndReplaceTabs)核心(server/syncCore.mjs; perm-run03 起依來源鍵 srcKey 差異同步, R-IMPORT-01): 與後台整表寫入共用同一個鎖管理器(lockTabs).
334
+ //syncWarns(同一 from 同名異 srcKey 之疑似來源鍵重編提示)之登錄亦在其內(記憶體, 重啟即清; 不含個資, 僅表名、來源名稱、筆數、時間)
335
+ let sc = syncCore({ woItems, procOrm, lockTabs, srLog, mappingBy, cascadeRename })
339
336
 
340
337
 
341
338
  //lockSave: 後台整表寫入(updateTabItems)之雙擊防護(後端, 見 lockSave.mjs, ADR-025): 以「updateTabItems:{表名}:{操作者 id}」於 cacheSt 原子占位,
342
- //同一操作者之同表寫入處理中再送出即 reject 'saveInProgress'(不排隊); 包在 kmx 之外層, 不同操作者之並行仍由 kmx 序列化(ADR-012)
339
+ //同一操作者之同表寫入處理中再送出即 reject 'saveInProgress'(不排隊); 包在表鎖之外層, 不同操作者之並行仍由表鎖序列化(ADR-012)
343
340
  let cst = cacheSt()
344
341
  let lockSave = createLockSave(cst)
345
342
 
346
343
 
347
344
  //procCore: 整表批次 diff 寫入邏輯 (updateTabItems + updateTargets/Pemis/Grups/Users)
348
- let pc = procCore(woItems, procOrm, { srLog, kmx, lockSave, mappingBy, cascadeRename, lockExternalRows }) //mappingBy 供 users 整表寫入的對應鍵檢查(R-PRINCIPLE-00 適用表 h); cascadeRename 供群組/權限改名之引用級聯(適用表 m); lockExternalRows 供後台對外部列之守門(F-23)
345
+ let pc = procCore(woItems, procOrm, { srLog, withTabLocks: lockTabs, guardWrite, lockSave, mappingBy, cascadeRename, lockExternalRows }) //mappingBy 供 users 整表寫入的對應鍵檢查(R-PRINCIPLE-00 適用表 h); cascadeRename 供群組/權限改名之引用級聯(適用表 m); lockExternalRows 供後台對外部列之守門(F-23)
349
346
 
350
347
 
351
348
  //procStaInfor: 統計資訊, fdLog 對齊 srLog 使用同一目錄(讀 settings.logFd, fallback 邏輯同 srLog.mjs); mock 由 settings.staEventMock 注入(e2e 統計圖穩定用)
@@ -384,7 +381,7 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
384
381
 
385
382
  cascadeRename, //改名是否連帶更新引用(opt.cascadeRename), 供清單頁儲存前預覽與伺服端一致(R-REF-CASCADE-01)
386
383
  lockExternalRows: lockExternalRows ? 'y' : 'n', //後台是否不得改外部列(opt.lockExternalRows), 供清單頁逐列唯讀、複製重置 from 與伺服端一致(R-EXT-OWN-01)
387
- syncWarns: getSyncWarns(), //外部同步疑似 id 重編之提示(同一 from 同名異 id), 供清單頁橫幅(原則 3: 提示); 不含個資, 僅表名、來源名稱、筆數、時間
384
+ syncWarns: sc.getSyncWarns(), //外部同步疑似來源鍵重編之提示(同一 from 同名異 srcKey), 供清單頁橫幅(原則 3: 提示); 不含個資, 僅表名、來源名稱、筆數、時間
388
385
 
389
386
  widthTargetId,
390
387
  widthTargetDescription,
@@ -468,8 +465,8 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
468
465
  }
469
466
 
470
467
  //userFind, 對應鍵須唯一(F-14, 規則帳本 R-MAPKEY-02): 命中多於 1 筆即拒絕, 不取首筆(取首筆等於由資料庫列序決定登入者為誰)
471
- let userRs = await woItems.users.select({ [mappingBy]: vSelf, isActive: 'y' })
472
- let { user: userFind, count: countFind } = pickUniqueUser(userRs)
468
+ //mappingBy 為 'id' 時(perm-run04, R-MAPKEY-04, O1): 後台列(from 空)對 id、外部列(from 非空)對 srcKey(外部列之 id 是本系統配發之內部主鍵, 不是登入識別); 判準單一擁有者 keyCheck.findLoginUsers
469
+ let { user: userFind, count: countFind } = await findLoginUsers(woItems.users, mappingBy, vSelf)
473
470
  // console.log('userFind', userFind)
474
471
 
475
472
  //check, 多筆: 只記 mappingBy 與筆數, 不記使用者之對應值(email/name 為個資); 0 筆行為不變
@@ -497,14 +494,19 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
497
494
 
498
495
 
499
496
  //getAndVerifyClientUser
500
- let getAndVerifyClientUser = async (token, from = '') => {
497
+ let getAndVerifyClientUser = async (token, from = '', { adminOnly = false } = {}) => {
501
498
  //基於token查找client使用者, 會基於外部getUserByToken以及perm內部users進行查找並比對
499
+ //adminOnly 為 true 時(經 getAndVerifyAdminUser)於完整驗證通過後另要求 perm 列為有效之系統管理員(R-ADMIN-ONLY-01); 預設 false 者(/api/getPerm)一般使用者照常
502
500
 
503
501
  //getTokenUser
504
502
  let user = await getTokenUser(token)
505
503
 
506
- //verifyClientUser
507
- let b = verifyClientUser(user, from)
504
+ //isAdmin, 於交給部署方前判定(判準 adminCheck.isAdminRow, perm 列為權威)
505
+ let isAdmin = isAdminRow(user)
506
+
507
+ //verifyClientUser, 部署方只收到淺層副本(perm 列之欄位皆為純量): 其函數即使改寫收到之 user(id、isAdmin…)亦不影響本函數回傳之 user——
508
+ //後續以 user.id 為執行身分/查權限樹, 檢查之值即使用之值(perm-run01 外審 sol#2: 否則角色取自 A、執行 id 被改成 M)
509
+ let b = verifyClientUser({ ...user }, from)
508
510
  if (ispm(b)) {
509
511
  b = await b
510
512
  }
@@ -516,10 +518,27 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
516
518
  return Promise.reject(`userNoPermission`)
517
519
  }
518
520
 
521
+ //check admin, 一般使用者即使部署方放行亦拒絕(固定語系鍵 userNotAdmin; 只記 from, 不記 token 與使用者資料)
522
+ if (adminOnly && !isAdmin) {
523
+ srLog.error({ event: 'getAndVerifyAdminUser-error', msg: 'userNotAdmin', from })
524
+ return Promise.reject(`userNotAdmin`)
525
+ }
526
+
519
527
  return user
520
528
  }
521
529
 
522
530
 
531
+ //getAndVerifyAdminUser: 權限後台與資料通道只限系統管理員(perm-run01; 使用者 2026-10-24 裁示「系統管理員才能讀寫 4 表, 一般使用者是沒法進權限後台的, 故只能用 token 查自己權限樹」; 規則帳本 R-ADMIN-ONLY-01)
532
+ //在 getAndVerifyClientUser 之完整驗證(外部 getUserByToken → perm users 存在且 isActive='y' → 部署方 verifyClientUser)之後, 另要求該使用者為 perm users 表中 isAdmin='y' 且 isActive='y':
533
+ //perm 以自身 users 表為權威, 不採 token(getUserByToken)宣稱之 isAdmin(user 為 getTokenUser 當次新鮮讀取之 perm 列, 不快取); 不符即 reject 固定語系鍵 userNotAdmin(fail-closed), 記 srLog(只記 from, 不記 token 與使用者資料).
534
+ //部署方 verifyClientUser 仍照常套用(只能再收緊), 不必再以 from 區分管理員與一般使用者. 唯一不經本函數之使用者入口為 /api/getPerm(一般使用者以 token 查本人權限樹), 以及應用系統路由(verifyAppUser).
535
+ //判準單一擁有者 adminCheck.isAdminRow(與 requireAdmin 共用)
536
+ //角色於 getTokenUser 回傳當下判定(早於交給部署方 verifyClientUser), 使「檢查之值即使用之值」: 部署方函數即使修改其收到之 user 物件亦不影響角色判定; 但拒絕鍵次序為先 userNoPermission(部署方否決)後 userNotAdmin
537
+ let getAndVerifyAdminUser = async (token, from = '') => {
538
+ return await getAndVerifyClientUser(token, from, { adminOnly: true })
539
+ }
540
+
541
+
523
542
  //getAndVerifyAppUser
524
543
  let getAndVerifyAppUser = async (token, from = '') => {
525
544
  //基於token查找app使用者, 會基於外部getUserByToken進行查找, 故只要getUserByToken提供使用者即可, 可支援getUserByToken給予針對應用程式所產生之虛擬使用者
@@ -567,10 +586,11 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
567
586
  //改為與登入入口對稱, 使 UI 之「是否有效」與部署層 verifyClientUser 政策對資料通道亦生效.
568
587
  //from 為 w-converhp 之 apiType('main' / 'upload-controller' / 'upload-slice' / 'download-get-filename' / 'download-get-file' / 'download'),
569
588
  //供部署層 verifyClientUser(user, from) 區分入口.
589
+ //perm-run01(R-ADMIN-ONLY-01): 資料通道只限系統管理員——完整驗證通過後另要求 perm 列 isAdmin='y' 且 isActive='y', 所有入口(main 之 RPC/表 select/系統函數, 上傳下載各 apiType)同一道閘; 一般使用者即使部署方放行亦拒絕(只能用 /api/getPerm 查本人權限樹)
570
590
  checkToken: async(token, from = '') => {
571
591
 
572
- //getAndVerifyClientUser, reject 時 key: cannotFindUserFromToken / cannotGetUserFromPerm / userNoPermission 等
573
- await getAndVerifyClientUser(token, from)
592
+ //getAndVerifyAdminUser, reject 時 key: cannotFindUserFromToken / cannotGetUserFromPerm / userNoPermission / userNotAdmin 等
593
+ await getAndVerifyAdminUser(token, from)
574
594
 
575
595
  return true
576
596
  },
@@ -625,290 +645,6 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
625
645
  }
626
646
 
627
647
 
628
- //syncAndReplaceTabs
629
- let syncAndReplaceTabs = async (userId, inp, keyTable) => {
630
-
631
- //序列化同表之並行整表 delAll+insert: 防 lost update (同 keyTable 序列化, 不同表並行)
632
- //與後台整表寫入(procCore.updateTabItems)共用同表互斥鍵 tabWriteKey: 兩者各自先檢查名稱/對應鍵唯一後寫入, 鍵不同則交錯可略過 F-02/F-14(外審 sol R10);
633
- //有改名或刪除而須改寫引用端時, 於內層依階層再取引用端表之鎖(同步群組/權限/標的時, 被刪除或改名者之引用須連帶清理/更新, R-REF-CASCADE-01), 皆經單一取鎖函數(R-WRITE-LOCK-02)
634
- return await withTabLocks(kmx, [keyTable], async () => {
635
-
636
- //from
637
- let from = get(inp, 'from', '')
638
- // console.log('from', from)
639
-
640
- //check
641
- if (!isestr(from)) {
642
- console.log('inp', inp)
643
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidFrom', keyTable })
644
- return Promise.reject(`invalidFrom`)
645
- }
646
-
647
- //rows
648
- let rows = get(inp, 'rows', [])
649
- // console.log(keyTable, rows)
650
-
651
- //check
652
- if (!isearr(rows)) {
653
- console.log('inp', inp)
654
- console.log('keyTable', keyTable)
655
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', keyTable })
656
- return Promise.reject(`invalidRows`)
657
- }
658
-
659
- //各列須為非空物件(規則帳本 R-ORM-05, 與後台整表寫入共用 isRowObj), 否則整批拒絕且不進 delAll:
660
- //修正前非物件元素(字串、數字、null)使下方 v.from = from 拋 TypeError, 經 pm2resolve 把引擎原文外送給外部應用(R-ORM-02), 元素為 {} 時則寫入一筆自動主鍵之空白列
661
- let kBadRow = rows.findIndex((v) => !isRowObj(v))
662
- if (kBadRow >= 0) {
663
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: 'rowInvalid', keyTable, from, row: kBadRow })
664
- return Promise.reject(`invalidRows`)
665
- }
666
-
667
- //keyAssign/hasAssign: 後台可寫之指派欄(users.cgrups/grups.cpemis/pemis.crules; targets 無)及各列是否自帶該鍵(須於 ltdtmapping 缺鍵補 '' 之前記錄);
668
- //缺該鍵者於下方保留庫內指派(F-23, 指派欄屬後台, 見 syncKeep.mjs)
669
- let keyAssign = (keyTable === 'users') ? 'cgrups' : ((keyTable === 'grups') ? 'cpemis' : ((keyTable === 'pemis') ? 'crules' : null))
670
- let hasAssign = (keyAssign !== null) ? markHasKey(rows, keyAssign) : null
671
-
672
- //save from
673
- rows = map(rows, (v, k) => {
674
- // v.order = k + 1 //order大部分由外部給予, 不能複寫
675
- v.from = from
676
- return v
677
- })
678
-
679
- //ltdtmapping
680
- rows = ltdtmapping(rows, ds[keyTable].keys)
681
- // console.log('ltdtmapping rows', rows)
682
-
683
- //映射到 schema 欄位後, 除伺服器管理欄(from 與建立者/建立時間/更新者/更新時間, 寫入時由伺服器覆寫)外, 沒有任何欄位帶有內容者為空白列, 整批拒絕(規則帳本 R-ORM-05; 外審 minimax#3、sol#3/#4):
684
- //上方 isRowObj 只擋 {} 與非物件, {"foo":1}、{"length":5} 等「只含非 schema 欄位」之列映射後與 {} 相同, 仍會被 insert 成自動主鍵之空白列;
685
- //只帶伺服器管理欄(如 {"userId":"x"})之列, 該值於寫入時被覆寫, 結果同為空白列; 欄位值為 []/{} 等空容器亦非內容.
686
- //內容 = 非空字串、有限數字(0 亦是)、布林
687
- let kpSrvKey = { from: true, userId: true, timeCreate: true, userIdUpdate: true, timeUpdate: true }
688
- let isContent = (x) => (typeof x === 'string' && x !== '') || (typeof x === 'number' && Number.isFinite(x)) || typeof x === 'boolean'
689
- let kBlank = rows.findIndex((v) => !Object.keys(v).some((k) => kpSrvKey[k] !== true && isContent(v[k])))
690
- if (kBlank >= 0) {
691
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: 'rowBlank', keyTable, from, row: kBlank })
692
- return Promise.reject(`invalidRows`)
693
- }
694
-
695
- //名稱/標的 id 須為非空字串(規則帳本 R-SYNC-NAME-01, 主控複審 R2): 比照後台整表寫入之 ckKey(procCore.mjs: isestr(value)),
696
- //grups/pemis 之 name、targets 之 id 缺漏、空字串、非字串(數字、布林、null)一律整批拒收, 於 delAll 之前, 該 from 與他 from 之既有列不變.
697
- //why: 修正前 name:'' 之群組/權限可入庫, 伺服端 arrFinder 以 === 採納之(授權有效), 編輯器(cvKpToObj/doSave)視空名稱為無效, 管理者存檔即移除該授權
698
- //(移除 AND 限制時等於增權); 與後台路徑(ckKey 拒收空名稱)不對稱. users 不在範圍(name 可重複); 不比照 ckKey 之重複偵測(複審範圍僅「非空」)
699
- let keyName = (keyTable === 'targets') ? 'id' : ((keyTable === 'grups' || keyTable === 'pemis') ? 'name' : null)
700
- if (keyName !== null) {
701
- let kName = rows.findIndex((v) => !isestr(get(v, keyName)))
702
- if (kName >= 0) {
703
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: (keyName === 'id') ? 'idInvalid' : 'nameInvalid', keyTable, from, row: kName, field: keyName })
704
- return Promise.reject(`invalidRows`)
705
- }
706
- }
707
-
708
- //群組/權限名稱唯一(F-02, 規則帳本 R-NAME-UNIQ-01, ADR-034), 本批 rows 內 name 精確 === 重複整批拒收(他 from 已使用者見下方 nameOtherFrom), 於 delAll 之前, 該 from 與他 from 之既有列不變.
709
- //why: 授權以名稱查找(arrFinder 取第一個同名列), 同名並存即授權取決於資料庫列序; 後台 ckKey/updateTabItems 早已拒同名, 外部同步原不拒(ADR-031 決定 4 曾明載不比照), 兩入口不對稱.
710
- //外部應用只收固定語系鍵 invalidRows(規則帳本 R-ORM-02); targets 之 id 為主鍵(validateRowsBeforeWrite 已查批內重複), users 之 name 允許重複, 皆不在此
711
- if (keyName === 'name') {
712
- let kDup = findDupIndex(rows, 'name')
713
- if (kDup >= 0) {
714
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: 'nameDuplicate', keyTable, from, row: kDup, field: 'name' })
715
- return Promise.reject(`invalidRows`)
716
- }
717
- }
718
-
719
- //users 對應鍵批內檢查(F-14, 規則帳本 R-MAPKEY-01, ADR-034): mappingBy 為 'email'/'name' 時, 每個 isActive==='y' 之列該欄位須非空字串且批內唯一(精確 ===); 'id'(預設)不另檢查;
720
- //只有「對應鍵」才是登入查找(getTokenUser)之鍵, 其唯一性才是安全前提——預設部署之 email 可空/可重複, 不得因新規則使其外部同步全數失敗. log 不記對應值(個資)
721
- if (keyTable === 'users') {
722
- let mk = checkMapKeyBatch(rows, mappingBy)
723
- if (mk !== null) {
724
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: mk.reason, keyTable, from, row: mk.k, field: mappingBy })
725
- return Promise.reject(`invalidRows`)
726
- }
727
- }
728
-
729
- //寫入前驗證(規則帳本 R-ORM-01, ADR-026): 於 delAll 之前擋下可預判之寫入失敗, 該 from 之既有列不變(ADR-008 防誤清源).
730
- //why: 先 delAll 再 insert, 而 w-serv-orm 1.1.8 起對「會被 clearXSS 改寫之主鍵」整批拒絕、LMDB 對 > 1978 B 之主鍵整批拒絕,
731
- //修正前遇之即該 from 之既有列全數清空(2026-10-14 V4/V7 實測). 主鍵問題 → invalidRows; 任一字串欄位含標記 → htmlMarkupNotAllowed
732
- let ve = validateRowsBeforeWrite(rows)
733
- if (ve !== null) {
734
- let key = (ve.reason === 'htmlMarkup') ? 'htmlMarkupNotAllowed' : 'invalidRows'
735
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, reason: ve.reason, keyTable, from, row: ve.k, field: ve.field })
736
- return Promise.reject(key)
737
- }
738
-
739
- //跨 from 同鍵(規則帳本 R-SYNC-01): 本批有給之主鍵若已屬其他 from, 整批拒絕且不進 delAll(本 from 與他 from 之既有列皆不變).
740
- //why: insert 對已存在之主鍵略過而整批仍回 success, 該列不會寫入本 from(靜默遺失); w-serv-orm 1.1.11 起數字主鍵存為其字串形式(原被換成隨機主鍵),
741
- //以流水號為主鍵之各應用即互撞(2026-10-14 w-serv-orm 實測). 此處直呼 woItems(不經 procOrm 之轉型), 查詢條件一律用 String(id)(R-ORM-04);
742
- //同 from 之既有列不算衝突(隨後由 delAll 重寫); 本段與 delAll/insert 同在 kmx 內(同表之並行同步已序列化)
743
- let keyOfId = (v) => {
744
- let id = get(v, 'id')
745
- return (isestr(id) || (typeof id === 'number' && Number.isFinite(id))) ? String(id) : null
746
- }
747
- let sids = rows.map(keyOfId).filter((s) => s !== null)
748
- if (sids.length > 0) {
749
- let rsExist = null
750
- try {
751
- rsExist = await woItems[keyTable].select({ id: { $in: sids } })
752
- }
753
- catch (err) {
754
- let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
755
- let key = toOutKey(err, 'anUnexpectedErrorOccurred') //尚未寫入任何資料, 不回 saveWriteFail
756
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
757
- return Promise.reject(key)
758
- }
759
- let fromById = new Map()
760
- for (let v of rsExist) {
761
- fromById.set(String(v.id), v.from)
762
- }
763
- let k = rows.findIndex((v) => {
764
- let s = keyOfId(v)
765
- return s !== null && fromById.has(s) && fromById.get(s) !== from
766
- })
767
- if (k >= 0) {
768
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: 'idOtherFrom', keyTable, from, row: k, field: 'id' })
769
- return Promise.reject('invalidRows')
770
- }
771
- }
772
-
773
- //跨 from 同名/同對應鍵(F-02/F-14, 規則帳本 R-NAME-UNIQ-01/R-MAPKEY-01): grups/pemis 之 name、users 於 mappingBy 為 email/name 時之該欄位(僅 active 列),
774
- //已為其他 from 之既有列使用 → 整批拒絕且不進 delAll; 同 from 之既有列不算衝突(隨後由 delAll 重寫). 先 select 後比對, select 失敗之處置同上段(toOutKey, 未寫入任何資料)
775
- let keyOther = (keyName === 'name') ? 'name' : ((keyTable === 'users') ? mapKeyOf(mappingBy) : null)
776
- if (keyOther !== null) {
777
- let rsAll = null
778
- try {
779
- rsAll = await woItems[keyTable].select()
780
- }
781
- catch (err) {
782
- let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
783
- let key = toOutKey(err, 'anUnexpectedErrorOccurred') //尚未寫入任何資料, 不回 saveWriteFail
784
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
785
- return Promise.reject(key)
786
- }
787
- let kOther = findKeyOtherFrom(rows, rsAll, from, keyOther, { onlyActive: keyTable === 'users' })
788
- if (kOther >= 0) {
789
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: 'invalidRows', reason: (keyTable === 'users') ? 'mapKeyOtherFrom' : 'nameOtherFrom', keyTable, from, row: kOther, field: keyOther })
790
- return Promise.reject('invalidRows')
791
- }
792
- }
793
-
794
- //外部同步 payload 缺指派欄鍵之列保留庫內值(F-23; 主控定案: 指派欄屬後台, 同步不得清掉後台指派; 含該鍵則以外部為準): 修正前 ltdtmapping 缺鍵補 ''、delAll+insert 即清空後台已指派者.
795
- //三個指派欄(users.cgrups/grups.cpemis/pemis.crules; run08 起 cpemis/crules 比照 cgrups)皆是; 只在確有缺鍵之列時才讀庫內(同一 from).
796
- //users: 對應先依 id、無 id 者依 mappingBy(email/name)恰命中 1 筆, 對不到者視為新使用者(維持 ''), 對應鍵矛盾者不保留(keepAssignOnMissing);
797
- //grups/pemis: 無登入對應鍵, 實體身分依 id(無 id 者依名稱恰 1 筆; 改名而 cascadeRename 為假者不保留; keepAssignByEntity). select 失敗之處置同上段(toOutKey, 尚未寫入任何資料)
798
- if (hasAssign !== null && hasAssign.some((b) => b === false)) {
799
- let rsSame = null
800
- try {
801
- rsSame = await woItems[keyTable].select({ from }) //此處 keyTable 為 users/grups/pemis(hasAssign 非 null); users 非登入查找, 不經 pickUniqueUser(SITE-KC-004 只管登入反查)
802
- }
803
- catch (err) {
804
- let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
805
- let key = toOutKey(err, 'anUnexpectedErrorOccurred')
806
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
807
- return Promise.reject(key)
808
- }
809
- //isKeepable: 保留值於 delAll 之前須再通過一次寫入前驗證(上方 validateRowsBeforeWrite 看到的是補出之空字串, 外審 sol R4); 不過者不保留(視為空, 收嚴), 只記筆數
810
- let isKeepable = (val) => validateRowsBeforeWrite([{ id: 'keep', [keyAssign]: val }]) === null
811
- let kr = (keyTable === 'users') ? keepAssignOnMissing({ rows, hasKey: hasAssign, rowsOldSameFrom: rsSame, mappingBy, isKeepable }) : keepAssignByEntity({ rows, hasKey: hasAssign, rowsOldSameFrom: rsSame, key: keyAssign, cascadeRename, isKeepable })
812
- rows = kr.rows
813
- if (kr.nKeep > 0) {
814
- srLog.info({ event: 'syncAndReplaceTabs-keepAssign', keyTable, from, field: keyAssign, n: kr.nKeep })
815
- }
816
- if (kr.nSkip > 0) {
817
- srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nSkip, reason: 'notKeepable' })
818
- }
819
- if (kr.nConflict > 0) {
820
- srLog.warn({ event: 'syncAndReplaceTabs-keepAssign-skip', keyTable, from, field: keyAssign, n: kr.nConflict, reason: (keyTable === 'users') ? 'mapKeyConflict' : 'identityConflict' }) //users: 對應鍵與 id 指向不同使用者; grups/pemis: 同名多筆無從對應或改名而引用不跟著改; 指派不移轉而清空(收嚴須附提示; 不含個資值)
821
- }
822
- }
823
-
824
- //引用連帶(適用表 m/n, R-REF-CASCADE-01): 同步 grups/pemis/targets 時, 同一 from 內依穩定 id 判定——同 id 異名=改名級聯、舊 id 不在本批=刪除而連帶清理引用(寫入後仍存續之同名者不清理);
825
- //未帶 id 之列(ORM 每次補隨機主鍵)改依名稱對應(見 buildSyncRowsNew). 預檢(新名撞懸空名、歷史同名組改名、容器內相撞、改寫後欄位驗證)於 delAll 之前, 被拒整批零變更;
826
- //外部應用只收固定語系鍵 invalidRows/htmlMarkupNotAllowed(R-ORM-02), 動態原因入 srLog. select 失敗之處置同上段(toOutKey, 未寫入任何資料).
827
- //是否須改寫引用端只依本表新舊列判定(diffRefTargets), 持有本表鎖之下即可決定是否再取引用端表之鎖(R-WRITE-LOCK-02); 取齊鎖之後才讀取引用端、規劃與預檢
828
- let rowsPlan = null
829
- let kind = null
830
- if (Object.prototype.hasOwnProperty.call(REF_OF, keyTable)) {
831
- try {
832
- let rowsOld = await woItems[keyTable].select()
833
- rowsPlan = { rowsOld, rowsNew: buildSyncRowsNew(keyTable, rowsOld, rows, from) }
834
- }
835
- catch (err) {
836
- let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
837
- let key = toOutKey(err, 'anUnexpectedErrorOccurred')
838
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
839
- return Promise.reject(key)
840
- }
841
- kind = diffRefTargets({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, opt: { cascadeRename } })
842
-
843
- //外部 id 不穩定之偵測(主控定案: 不加開關; 同一 from 同一批內出現「同名異 id」=疑似 id 重編, id 消失視為刪除會每次清理引用而可能抹掉後台指派): 記 warn log(不記個資值, 只記筆數)
844
- //並登錄供 getWebInfor.syncWarns 於頁面提示; 之後同一(表, from)之同步已無此情形即清除提示. 只適用有 name 之 grups/pemis(users 之 name 可重複, targets 之 id 即名稱)
845
- if (keyTable === 'grups' || keyTable === 'pemis') {
846
- let nRenum = countIdRenumber(rowsPlan.rowsOld.filter((o) => o !== null && typeof o === 'object' && o.from === from), rows)
847
- setSyncWarn(keyTable, from, nRenum)
848
- if (nRenum > 0) {
849
- srLog.warn({ event: 'syncAndReplaceTabs-idRenumber', keyTable, from, n: nRenum })
850
- }
851
- }
852
- }
853
-
854
- //doWrite: delAll from, insert; ORM/I/O 失敗之原文只入 srLog, 外部應用收 saveWriteFail(規則帳本 R-ORM-02); 引用端先寫、失敗補償還原(cascadeWrite.mjs)
855
- let doWrite = async (plan, holdersOld) => {
856
- let r = null
857
- try {
858
- r = await writeWithCascade({
859
- plan,
860
- holdersOld,
861
- woItems,
862
- procOrm,
863
- userId,
864
- srLog,
865
- writeMain: async () => {
866
- await woItems[keyTable].delAll({ from })
867
- return await procOrm(userId, keyTable, 'insert', rows) //須使用procOrm才有辦法自動給予相關欄位
868
- },
869
- })
870
- }
871
- catch (err) {
872
- let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
873
- let key = toOutKey(err)
874
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from })
875
- return Promise.reject(key)
876
- }
877
- // console.log('r', r)
878
- return r
879
- }
880
-
881
- //有改名或刪除: 再取引用端表之鎖, 讀取引用端、規劃、預檢後寫入
882
- if (kind !== null && (kind.renames.length > 0 || kind.delNames.length > 0)) {
883
- let holder = REF_OF[keyTable].holder
884
- return await withTabLocks(kmx, [holder], async () => {
885
- let holdersOld = null
886
- try {
887
- holdersOld = await woItems[holder].select()
888
- }
889
- catch (err) {
890
- let raw = (typeof err === 'string') ? err : ((err && err.message) || String(err))
891
- let key = toOutKey(err, 'anUnexpectedErrorOccurred')
892
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, raw, keyTable, from, step: 'select' })
893
- return Promise.reject(key)
894
- }
895
- let plan = planRefChanges({ keyTable, rowsOld: rowsPlan.rowsOld, rowsNew: rowsPlan.rowsNew, holdersOld, opt: { cascadeRename } })
896
- let errPlan = plan.err !== null ? plan.err : checkCascadePlan(plan)
897
- if (errPlan !== null) {
898
- let key = (errPlan === 'htmlMarkupNotAllowed') ? 'htmlMarkupNotAllowed' : 'invalidRows'
899
- srLog.error({ event: 'syncAndReplaceTabs-error', msg: key, reason: errPlan, keyTable, from })
900
- return Promise.reject(key)
901
- }
902
- return await doWrite(plan, holdersOld)
903
- }, [keyTable])
904
- }
905
-
906
- return await doWrite(null, [])
907
-
908
- })
909
- }
910
-
911
-
912
648
  //getGenUserByKV
913
649
  let getGenUserByKV = async(keyUser, valueUser) => {
914
650
 
@@ -925,6 +661,46 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
925
661
  }
926
662
 
927
663
 
664
+ //requireAdmin: 函數層級之角色檢查(perm-run03 範圍四; dspec/使用者身分驗證原則.md 二.4「函數層級須另檢查角色, 不得只靠部署方之授權設定把關」; w-web呼叫者身分驗證.md C2, 使用者 2026-10-23 裁示採②):
665
+ //改變狀態之 kpFunExt 函數(updateTargets/updatePemis/updateGrups/updateUsers; getWebInfor、getStaEvent、getStaEventTable 為唯讀不在此列)限 perm users 表中有效(isActive='y')且 isAdmin='y' 之使用者.
666
+ //perm 以自身 users 表為權威, 不採 token(getUserByToken)宣稱之 isAdmin(同 getTokenUser); userId 為 getUserIdByToken 回傳之 perm 內 id, 此處再新鮮讀取一次(角色可能於連線後被改, 不信任快取).
667
+ //不符即 reject 固定錯誤鍵 userNotAdmin、零寫入(先於 lockSave/表鎖/任何讀庫), 記 srLog(只記函數名與 userId, 不記 token 與使用者資料); 讀取失敗(ORM 例外)一律視同不符(fail-closed)
668
+ let requireAdmin = async (userId, funcName) => {
669
+ let u = null
670
+ try {
671
+ u = await getGenUserByKV('id', userId)
672
+ }
673
+ catch (err) {
674
+ u = null
675
+ }
676
+ if (!isAdminRow(u)) { //縱深防禦: 資料通道已於連線閘與執行身分要求管理員(R-ADMIN-ONLY-01), 此處對新鮮讀取之列再判一次(連線後角色被降級之競態)
677
+ srLog.error({ event: 'requireAdmin-error', msg: 'userNotAdmin', funcName, userId })
678
+ return Promise.reject('userNotAdmin')
679
+ }
680
+ return u
681
+ }
682
+
683
+
684
+ //resolvePermUserId: /api/getPermUserInfor 之 userId(外部應用傳入)依登入對應解析(perm-run05, 規則帳本 R-MAPKEY-05; 主控 2026-10-24 收件裁示②):
685
+ //mappingBy 為 'id' 時, 外部應用手上只有外部使用者 id(即登入識別: 外部列對 srcKey、後台列對 id), 不知本系統配發之內部 id, 故與登入入口同一判準(keyCheck.findLoginUsers)解析:
686
+ //命中多筆(不論有效與否)→ 拒絕 ambiguousUserFromPerm(不取首筆); 恰 1 筆有效 → 回 { userId: 其內部 id, userFind: 該列 };
687
+ //其餘(查無或恰 1 筆停用)→ 回 { userId: null, userFind: null }(視同查無使用者, 回全停用之空權限, 與修正前查無使用者行為相同);
688
+ //以解析出之列直接算權限, 不再以內部 id 重讀(外審 sol 第一輪#2: 解析與重讀之間該列可被刪除、同 id 被另一人佔用, 重讀會取得另一人之權限);
689
+ //'email'/'name' 時 userId 維持內部 id(行為不變, userFind 為 undefined 表示照舊以 id 讀取). 僅此路由使用: /api/getPerm 之 userIdSelf 已是 getTokenUser 回傳之內部 id, 不得再解析.
690
+ let resolvePermUserId = async (userIdFind) => {
691
+ if (mapKeyOf(mappingBy) !== null) {
692
+ return { userId: userIdFind, userFind: undefined }
693
+ }
694
+ let { user, count } = await findLoginUsers(woItems.users, mappingBy, userIdFind)
695
+ if (count > 1) {
696
+ console.log('mappingBy', mappingBy, 'count', count)
697
+ srLog.error({ event: 'api/getPermUserInfor-error', msg: 'ambiguousUserFromPerm', mappingBy, count })
698
+ return Promise.reject('ambiguousUserFromPerm')
699
+ }
700
+ return iseobj(user) ? { userId: user.id, userFind: user } : { userId: null, userFind: null }
701
+ }
702
+
703
+
928
704
  //getGenUserByUserId
929
705
  let getGenUserByUserId = async(userId) => {
930
706
 
@@ -941,10 +717,10 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
941
717
 
942
718
 
943
719
  //getGenUserAndRulesByUserId
944
- let getGenUserAndRulesByUserId = async (userId) => {
720
+ let getGenUserAndRulesByUserId = async (userId, userPre = undefined) => {
945
721
 
946
- //userFind
947
- let userFind = await getGenUserByUserId(userId)
722
+ //userFind, userPre 非 undefined 時(含 null=查無)為呼叫端已解析之列, 直接採用不重讀(resolvePermUserId); 否則依 userId 讀取
723
+ let userFind = (userPre !== undefined) ? userPre : await getGenUserByUserId(userId)
948
724
  // console.log('userFind', userFind)
949
725
 
950
726
  //grups
@@ -1054,8 +830,8 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1054
830
  return Promise.reject(`tokenNoPermission`)
1055
831
  }
1056
832
 
1057
- //getAndVerifyClientUser
1058
- let user = await getAndVerifyClientUser(token, 'getUserByToken')
833
+ //getAndVerifyAdminUser, 後台登入檢查只限系統管理員(perm-run01, R-ADMIN-ONLY-01): 一般使用者即使部署方放行亦拒絕(userNotAdmin, 前端顯示「無權進入權限後台」); 一般使用者查本人權限樹走 /api/getPerm
834
+ let user = await getAndVerifyAdminUser(token, 'getUserByToken')
1059
835
  // console.log('user', user)
1060
836
 
1061
837
  //check
@@ -1129,8 +905,9 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1129
905
  return Promise.reject(`tokenNoPermission`)
1130
906
  }
1131
907
 
1132
- //getGenUserAndRulesByUserId, 查找到的client使用者與權限
1133
- let userWithRulesSelf = await getGenUserAndRulesByUserId(userIdSelf)
908
+ //getGenUserAndRulesByUserId, 查找到的client使用者與權限; 第2參傳入驗證時取得之列(userSelf), 直接採用不以 id 重讀:
909
+ //驗證與重讀之間(含 verifyClientUser 為 async 之等待期間)該列可能被改登入鍵或刪除後同 id 換人, 重讀會回另一人之資料與權限樹(同 /api/getPermUserInfor 之 resolvePermUserId, perm-run01 外審 sol#3)
910
+ let userWithRulesSelf = await getGenUserAndRulesByUserId(userIdSelf, userSelf)
1134
911
  // console.log('getGenUserAndRulesByUserId', r)
1135
912
 
1136
913
  //check
@@ -1206,8 +983,11 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1206
983
  return Promise.reject(`tokenNoPermission`)
1207
984
  }
1208
985
 
1209
- //getGenUserAndRulesByUserId, 查找到的使用者與權限
1210
- let userWithRulesFind = await getGenUserAndRulesByUserId(userIdFind)
986
+ //resolvePermUserId, mappingBy 為 id 時依登入對應解析出使用者列(外部列傳 srcKey、後台列傳 id; 多筆拒絕), email/name 時維持內部 id
987
+ let userPerm = await resolvePermUserId(userIdFind)
988
+
989
+ //getGenUserAndRulesByUserId, 查找到的使用者與權限(已解析之列直接採用)
990
+ let userWithRulesFind = await getGenUserAndRulesByUserId(userPerm.userId, userPerm.userFind)
1211
991
  // console.log('getGenUserAndRulesByUserId', userWithRulesFind)
1212
992
 
1213
993
  //check
@@ -1303,7 +1083,7 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1303
1083
  let inp = await parsePayload(req)
1304
1084
 
1305
1085
  //syncAndReplaceTabs
1306
- let r = await syncAndReplaceTabs(userId, inp, keyTable)
1086
+ let r = await sc.syncAndReplaceTabs(userId, inp, keyTable)
1307
1087
 
1308
1088
  //info
1309
1089
  srLog.info({ event: 'api/syncAndReplaceTabs-success', userId, keyTable })
@@ -1342,6 +1122,10 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1342
1122
  verifyConn: async ({ apiType, authorization, query, headers, req }) => {
1343
1123
  // console.log('verifyConn', `apiType[${apiType}]`, `authorization[${authorization}]`)
1344
1124
 
1125
+ //身分驗證責任(dspec/使用者身分驗證原則.md 第三節, 使用方套件標準註解): 框架(w-converhp、w-serv-hapi、w-serv-webdata、w-serv-orm)不驗證 token, 一律由本套件負責.
1126
+ //連線閘(verifyConn)與執行身分(getUserIdByToken)皆須完整驗證(token 有效、本系統使用者存在且啟用、部署方授權); hapi apis 路由各自驗證; 函數層級另檢查角色或成員(updateXxx 限 perm users 表之 isAdmin='y', requireAdmin); 公開入口只限列名者.
1127
+ //本函數: 驗連線標頭之 token(getAndVerifyAdminUser: 外部 getUserByToken → perm users 存在且 isActive='y' → 部署方 verifyClientUser(user, apiType) → perm 列 isAdmin='y', R-ADMIN-ONLY-01: 權限後台與資料通道只限系統管理員, 一般使用者查本人權限樹走 /api/getPerm).
1128
+
1345
1129
  //token
1346
1130
  let token = strdelleft(authorization, 7) //刪除Bearer
1347
1131
  // console.log('token', token)
@@ -1391,11 +1175,25 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1391
1175
  },
1392
1176
  getUserIdByToken: async (token) => { //可使用async或sync函數
1393
1177
  // console.log('getUserIdByToken', token)
1394
- let user = await getUserByToken(token)
1178
+
1179
+ //身分驗證責任(dspec/使用者身分驗證原則.md 第三節, 使用方套件標準註解; 規則 w-web呼叫者身分驗證.md 規則 4): 框架(w-converhp、w-serv-hapi、w-serv-webdata、w-serv-orm)不驗證 token, 一律由本套件負責.
1180
+ //連線閘(verifyConn)與執行身分(getUserIdByToken)皆須完整驗證(token 有效、本系統使用者存在且啟用、部署方授權); hapi apis 路由各自驗證; 函數層級另檢查角色或成員; 公開入口只限列名者.
1181
+ //本函數: 資料通道只傳遞 token, 反查使用者即驗證身分之步驟(w-serv-orm 1.1.10 起 ORM 層不做使用者檢查): 與連線閘同等完整——經 getAndVerifyAdminUser(外部 getUserByToken → perm users 存在且 isActive='y' → 部署方 verifyClientUser → perm 列 isAdmin='y'),
1182
+ //任一關不過即 reject(框架據此不執行函數, fail-closed)並記 srLog, 不回顯 token 與使用者資料. 請求標頭與本體 __sysToken__ 縱為不同 token, 兩個身分亦各經完整驗證(框架不比對兩 token).
1183
+ //回傳 perm users 表內該使用者之 id(非外部 token 宣稱之 id): 稽核欄位(userId/userIdUpdate)與函數層級角色檢查(requireAdmin)皆以 perm 內之列為準; mappingBy 為 email/name 時兩者可不同.
1184
+ //from 取 'main': 本函數只在資料通道 RPC/表操作(apiType 'main')之執行身分反查時被框架呼叫(上傳下載通道無監聽), 與連線閘對同一請求所用之 apiType 一致, 使部署方 verifyClientUser 依 from 分流之政策兩處一致, 不引入新的 from 值.
1185
+ //perm-run01(R-ADMIN-ONLY-01): 與連線閘同為「只限系統管理員」——另要求 perm 列 isAdmin='y' 且 isActive='y'(getAndVerifyAdminUser), 否則 reject userNotAdmin, 框架即不執行函數(含唯讀函數與表 select).
1186
+ let user = null
1187
+ try {
1188
+ user = await getAndVerifyAdminUser(token, 'main')
1189
+ }
1190
+ catch (err) {
1191
+ let key = toOutKey(err, 'cannotFindUserId') //已知語系鍵原樣(cannotFindUserFromToken/cannotGetUserFromPerm/userNoPermission/userNotAdmin 等), 其餘(ORM/I/O 原文、Error)一律 cannotFindUserId, 不外送原文
1192
+ srLog.error({ event: 'getUserIdByToken-error', msg: key })
1193
+ return Promise.reject(key)
1194
+ }
1395
1195
  let userId = get(user, 'id', '')
1396
1196
  if (!isestr(userId)) {
1397
- console.log('token', maskTok(token))
1398
- console.log('userId', userId)
1399
1197
  srLog.error({ event: 'getUserIdByToken-error', msg: 'cannotFindUserId' })
1400
1198
  return Promise.reject(`cannotFindUserId`)
1401
1199
  }
@@ -1407,28 +1205,39 @@ function WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser
1407
1205
  //operOrm: procOrm 之包裝(輸入為 userId, tableName, methodName, input), ORM 拒絕之原文只入 srLog、對前端回 key(規則帳本 R-ORM-02)
1408
1206
  operOrm: wrapOperOrm(procOrm, { srLog }),
1409
1207
  tableNamesExec,
1410
- //資料通道只開 select(ADR-026、ADR-015 Update; 規則帳本 R-WSD-01): 前端同步只用 select, 寫入一律經 kpFunExt 之 updateXxx(lockSave、kmx、寫入前驗證、整表比對);
1208
+ //資料通道只開 select(ADR-026、ADR-015 Update; 規則帳本 R-WSD-01): 前端同步只用 select, 寫入一律經 kpFunExt 之 updateXxx(lockSave、表鎖、寫入前驗證、整表比對);
1411
1209
  //裸 insert/save/del/delAll 繞過上述不變式, 2026-10-14 起不開放(直呼回 invalid func[表名.方法]); 對自寫客戶端直呼裸表操作之部署方為行為變更(README Upgrade Notes)
1412
1210
  methodsExec: ['select'],
1413
1211
  //authorizeExec: w-serv-webdata 1.1.7 起 useDbOrm:true 必填; 只對已取得 userId 之 select 放行(methodsExec 之外之第二道), 拒絕記 srLog event 'authorizeExec'.
1414
- //連線者身分之授權在 verifyConn(getAndVerifyClientUser), 此處不重做
1212
+ //連線者身分之授權在 verifyConn(getAndVerifyAdminUser: 完整驗證+限系統管理員), 此處不重做
1415
1213
  authorizeExec: createAuthorizeExec({ srLog }),
1416
1214
  tableNamesSync,
1417
1215
  kpFunExt: { //接收參數第1個為userId, 之後才是前端給予參數
1418
1216
 
1419
1217
  getWebInfor,
1420
1218
 
1421
- //updateXxx 第 3 參 opt 為用戶端給之 { baseline }(草稿載入基準, F-26, R-BASELINE-01), 其餘鍵一律不採納(pickKeysOnly 等為伺服端設定); 未給(舊客戶端)見 procCore 之處置
1422
- updateTargets: (userId, rows, opt) => pc.updateTargets(userId, rows, opt),
1423
- updatePemis: (userId, rows, opt) => pc.updatePemis(userId, rows, opt),
1424
- updateGrups: (userId, rows, opt) => pc.updateGrups(userId, rows, opt),
1219
+ //updateXxx 第 3 參 opt 為用戶端給之 { baseline, returnRows }(草稿載入基準, F-26, R-BASELINE-01; 是否於成功回傳附寫入後之列, 規格第 8 節), 其餘鍵一律不採納(pickKeysOnly 等為伺服端設定, 更不轉給 procOrm); 未給(舊客戶端)見 procCore 之處置
1220
+ //四個寫入函數皆先經 requireAdmin(函數層級角色檢查, perm-run03 範圍四): 非 perm users 表中有效之系統管理員 → userNotAdmin、零寫入
1221
+ updateTargets: async (userId, rows, opt) => {
1222
+ await requireAdmin(userId, 'updateTargets')
1223
+ return await pc.updateTargets(userId, rows, opt)
1224
+ },
1225
+ updatePemis: async (userId, rows, opt) => {
1226
+ await requireAdmin(userId, 'updatePemis')
1227
+ return await pc.updatePemis(userId, rows, opt)
1228
+ },
1229
+ updateGrups: async (userId, rows, opt) => {
1230
+ await requireAdmin(userId, 'updateGrups')
1231
+ return await pc.updateGrups(userId, rows, opt)
1232
+ },
1425
1233
  //updateUsers, modeEditUsers='for:grups' 時 API 與 UI 一致只採納既有使用者之 cgrups 變更, 身分欄/新增/刪除/順序以資料庫為準
1426
- updateUsers: (userId, rows, optClient) => {
1427
- let opt = (optClient !== null && typeof optClient === 'object' && optClient.baseline !== undefined && optClient.baseline !== null) ? { baseline: optClient.baseline } : {}
1234
+ updateUsers: async (userId, rows, optClient) => {
1235
+ await requireAdmin(userId, 'updateUsers')
1236
+ let opt = pickClientOpt(optClient)
1428
1237
  if (modeEditUsers === 'for:grups') {
1429
1238
  opt.pickKeysOnly = ['cgrups']
1430
1239
  }
1431
- return pc.updateUsers(userId, rows, opt)
1240
+ return await pc.updateUsers(userId, rows, opt)
1432
1241
  },
1433
1242
 
1434
1243
  getStaEvent: async (userId, timeLength, timeInterval) => {