w-web-sso 1.1.12 → 1.1.13

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 (40) hide show
  1. package/README.md +16 -0
  2. package/dist/css/{app.b17b0ba0.css → app.f000459f.css} +1 -1
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/{app-legacy.bee6840f.js → app-legacy.7c006893.js} +1 -1
  5. package/dist/js/{app.bee6840f.js → app.7c006893.js} +1 -1
  6. package/dist/js/{chunk-vendors.0492b6f5.js → chunk-vendors-legacy.97013ddc.js} +5 -5
  7. package/dist/js/{chunk-vendors-legacy.0492b6f5.js → chunk-vendors.97013ddc.js} +5 -5
  8. package/dist/w-web-sso.umd.js +4 -4
  9. package/dist/w-web-sso.umd.js.map +1 -1
  10. package/docs/WWebSso.html +1 -1
  11. package/docs/global.html +1 -1
  12. package/docs/index.html +1 -1
  13. package/docs/server_WWebSso.mjs.html +16 -10
  14. package/docs/server_getIgnoredOrmKeys.mjs.html +1 -1
  15. package/docs/server_lockSave.mjs.html +1 -1
  16. package/docs/server_ormWrite.mjs.html +1 -1
  17. package/docs/server_staLogs_resolveShellWorker.mjs.html +1 -1
  18. package/docs/server_wrapTimer.mjs.html +1 -1
  19. package/docs/src_components_Common_CheckYesNo.vue.html +1 -1
  20. package/docs/src_fetchSsoMsg.mjs.html +1 -1
  21. package/docs/src_httpGetJson.mjs.html +1 -1
  22. package/docs/src_maskUrl.mjs.html +1 -1
  23. package/docs/src_plugins_staUsage.mjs.html +1 -1
  24. package/docs/src_plugins_submitGuard.mjs.html +1 -1
  25. package/package.json +2 -2
  26. package/server/WWebSso.mjs +15 -9
  27. package/server/procCore.mjs +791 -379
  28. package/server/procLang.mjs +18 -0
  29. package/server/procProtect.mjs +150 -88
  30. package/src/baselineOf.mjs +76 -0
  31. package/src/components/LayoutContentIps.vue +24 -2
  32. package/src/components/LayoutContentTokens.vue +24 -2
  33. package/src/components/LayoutContentUsers.vue +9 -3
  34. package/src/schema/tables/ips.mjs +4 -0
  35. package/src/schema/tables/tokens.mjs +4 -0
  36. package/src/writeContracts/index.mjs +9 -0
  37. package/src/writeContracts/ips.mjs +9 -0
  38. package/src/writeContracts/tokens.mjs +12 -0
  39. package/src/writeContracts/tokensSession.mjs +10 -0
  40. package/src/writeContracts/users.mjs +15 -0
@@ -7,6 +7,8 @@ import size from 'lodash-es/size.js'
7
7
  import keys from 'lodash-es/keys.js'
8
8
  import filter from 'lodash-es/filter.js'
9
9
  import isEqual from 'lodash-es/isEqual.js'
10
+ import isPlainObject from 'lodash-es/isPlainObject.js'
11
+ import { BASELINE_REJECT, digestRow, compareBaseline, isBaselineValid, pickWriteRow, getRejectKey } from 'w-serv-orm/src/baseline.mjs'
10
12
  import genIDSeq from 'wsemi/src/genIDSeq.mjs'
11
13
  import iseobj from 'wsemi/src/iseobj.mjs'
12
14
  import isestr from 'wsemi/src/isestr.mjs'
@@ -24,7 +26,6 @@ import j2o from 'wsemi/src/j2o.mjs'
24
26
  import strleft from 'wsemi/src/strleft.mjs'
25
27
  import strright from 'wsemi/src/strright.mjs'
26
28
  import strdelright from 'wsemi/src/strdelright.mjs'
27
- import ltdtDiffByKey from 'wsemi/src/ltdtDiffByKey.mjs'
28
29
  import ltdtmapping from 'wsemi/src/ltdtmapping.mjs'
29
30
  import haskey from 'wsemi/src/haskey.mjs'
30
31
  import arrHas from 'wsemi/src/arrHas.mjs'
@@ -46,12 +47,91 @@ import createLockSave from './lockSave.mjs'
46
47
  import ormWrite from './ormWrite.mjs'
47
48
  import { maskToken, maskKv, keysUserQuery, capText } from './srLog.mjs'
48
49
  import { hasAppPerm, isAppPermsValue, normAppPerms } from '../src/appPerms.mjs'
50
+ import contracts from '../src/writeContracts/index.mjs'
49
51
 
50
52
 
51
53
  //keysUserCredential: users 表之憑證欄(密碼雜湊 / 註冊驗證用 token), 對外介接輸出一律剝除 (ADR-068); 新增憑證欄時於此登錄
52
54
  let keysUserCredential = ['password', 'tokenVerify']
53
55
 
54
56
 
57
+ //kpSaveRejectKey: 寫入契約之拒收鍵(w-serv-orm baseline.mjs 之 BASELINE_REJECT, 固定英文) → 本套件語系鍵 (procLang), 一律提示重新載入 (R-CONC-03 規則 5)
58
+ let kpSaveRejectKey = Object.create(null)
59
+ kpSaveRejectKey[BASELINE_REJECT.mismatch] = 'saveStale'
60
+ kpSaveRejectKey[BASELINE_REJECT.rowNotFound] = 'saveRowNotFound'
61
+ kpSaveRejectKey[BASELINE_REJECT.format] = 'saveBaselineFormat'
62
+
63
+
64
+ //getSaveRejectKey: 由被 reject 之值取語系鍵; 非契約拒收者回 null. 取鍵一律用核心之 getRejectKey (procOrm 為字串, guardWrite 為帶 key 之 Error)
65
+ function getSaveRejectKey(err) {
66
+ let k = getRejectKey(err)
67
+ if (typeof k === 'string' && kpSaveRejectKey[k] !== undefined) {
68
+ return kpSaveRejectKey[k]
69
+ }
70
+ return null
71
+ }
72
+
73
+
74
+ //copyBaseline: 基準之防禦性複製, 只取資料屬性(RPC 之 JSON 不會帶存取器, 行程內直接呼叫才可能): 外層與各二元項須為陣列且二元項長度恰為 2,
75
+ //存取器 / 洞 / 取值例外一律回 null; 複製後之驗證與其後使用皆不再觸及客戶端物件, 存取器不被執行
76
+ function copyBaseline(b) {
77
+ try {
78
+ if (!Array.isArray(b)) {
79
+ return null
80
+ }
81
+ let out = []
82
+ for (let i = 0; i < b.length; i++) {
83
+ let dI = Object.getOwnPropertyDescriptor(b, i)
84
+ if (dI === undefined || !('value' in dI) || !Array.isArray(dI.value) || dI.value.length !== 2) {
85
+ return null
86
+ }
87
+ let d0 = Object.getOwnPropertyDescriptor(dI.value, 0)
88
+ let d1 = Object.getOwnPropertyDescriptor(dI.value, 1)
89
+ if (d0 === undefined || d1 === undefined || !('value' in d0) || !('value' in d1)) {
90
+ return null
91
+ }
92
+ out.push([d0.value, d1.value])
93
+ }
94
+ return out
95
+ }
96
+ catch (err) {
97
+ return null
98
+ }
99
+ }
100
+
101
+
102
+ //parseSaveOpt: 驗證前端經 RPC 送入之第 4 參數(clientOpt)並自組伺服端使用之基準; opt 只由伺服端自組, 不展開客戶端物件 (規格 §6).
103
+ //- undefined: 不帶基準(app 權杖直呼、已開啟之舊版頁面, 本 major 允許, 後者蓋前者), 回 {}
104
+ //- 其餘一律須為純物件且只含白名單鍵(users / ips: baseline; tokens: baseline 與 baselineSession 兩者皆須有), 基準格式或版本不識 → baselineFormat; 不得降級為不帶基準
105
+ //回傳 { baseline, baselineSession } 或 { err: 'baselineFormat' }
106
+ function parseSaveOpt(woName, clientOpt) {
107
+ let bad = { err: BASELINE_REJECT.format }
108
+ if (clientOpt === undefined) {
109
+ return {}
110
+ }
111
+ if (!isPlainObject(clientOpt)) {
112
+ return bad
113
+ }
114
+ let allow = woName === 'tokens' ? ['baseline', 'baselineSession'] : ['baseline']
115
+ let ks = Reflect.ownKeys(clientOpt)
116
+ if (ks.some((k) => allow.indexOf(k) < 0) || allow.some((k) => ks.indexOf(k) < 0)) {
117
+ return bad
118
+ }
119
+ let o = {}
120
+ for (let k of allow) {
121
+ let d = Object.getOwnPropertyDescriptor(clientOpt, k)
122
+ if (d === undefined || !('value' in d)) {
123
+ return bad
124
+ }
125
+ let v = copyBaseline(d.value)
126
+ if (v === null || !isBaselineValid(v)) {
127
+ return bad
128
+ }
129
+ o[k] = v
130
+ }
131
+ return o
132
+ }
133
+
134
+
55
135
  //maxKeyBytes: 主鍵 UTF-8 位元組數上限, 同 LMDB 預設鍵長上限(lmdb/open.js 之 DEFAULT_MAX_KEY_SIZE; 實測 1978 位元組可寫、1979 即拒), 供清單儲存於寫入前驗證 (ADR-081)
56
136
  let maxKeyBytes = 1978
57
137
 
@@ -82,7 +162,13 @@ function timingSafePasswordEqual(a, b) {
82
162
  }
83
163
 
84
164
 
85
- function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, passwordPolicy, allowUserRegistration, siteUrl, verifyBaseUrl }) {
165
+ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, passwordPolicy, allowUserRegistration, siteUrl, verifyBaseUrl, withTabLocks, guardWrite }) {
166
+
167
+ //withTabLocks / guardWrite: 取自 w-serv-orm WServOrm 實例(每實例一個鎖管理器、每表一鍵, 規格 §7); 所有寫入 users / tokens / ips 之站點皆須在該表表鎖內執行 (R-CONC-01),
168
+ //且與 procProtect 共用同一組(同表只由同一鎖管理器寫入, 規格 §12). 缺少即無法保證互斥, 故不提供預設替身
169
+ if (!isfun(withTabLocks) || !isfun(guardWrite)) {
170
+ throw new Error('procCore needs withTabLocks and guardWrite from the WServOrm instance')
171
+ }
86
172
 
87
173
 
88
174
  //pmKeyMutex: per-key in-memory mutex, 同 key 序列化、不同 key 並行.
@@ -360,22 +446,24 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
360
446
  let userId = get(u, 'id', '')
361
447
  // console.log('userId', userId)
362
448
 
363
- //createToken
364
- let token = await createToken(userId)
449
+ //createToken, recheckUser: 驗密碼(CPU 密集)於鎖外, 鑄 token 時於 tokens 鎖內重讀使用者狀態 (R-CONC-01 W2-2):
450
+ //登入與停用 / 刪除 / 自動封鎖 / 重設密碼(皆於 [tokens, users] 鎖內撤銷 token)交錯時, 不致於撤銷之後才鑄出屬於已停用、已刪除、封鎖中或密碼已變更使用者之 token;
451
+ //verifiedUser 為鎖外通過驗證所依據之使用者, 鎖內以新鮮資料再判密碼未變、已驗證、未到期; withUser 令回傳 { token, user } 取鎖內新鮮之使用者, 登入回傳值由同一次有效狀態產生
452
+ let { token, user: uNow } = await createToken(userId, { recheckUser: true, verifiedUser: u, withUser: true })
365
453
  // console.log('token', token)
366
454
 
367
455
  //r
368
456
  let r = {
369
- id: u.id,
370
- account: u.account,
371
- name: u.name,
372
- email: u.email,
373
- description: u.description,
374
- from: u.from,
375
- redir: u.redir,
376
- isAdmin: u.isAdmin,
377
- isActive: u.isActive,
378
- isForceChangePw: u.isForceChangePw, //強制變更密碼旗標, 前端登入後判斷是否拉去 user view 強制變更
457
+ id: uNow.id,
458
+ account: uNow.account,
459
+ name: uNow.name,
460
+ email: uNow.email,
461
+ description: uNow.description,
462
+ from: uNow.from,
463
+ redir: uNow.redir,
464
+ isAdmin: uNow.isAdmin,
465
+ isActive: uNow.isActive,
466
+ isForceChangePw: uNow.isForceChangePw, //強制變更密碼旗標, 前端登入後判斷是否拉去 user view 強制變更
379
467
  token,
380
468
  }
381
469
 
@@ -384,7 +472,11 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
384
472
 
385
473
 
386
474
  //createToken
387
- let createToken = async (userId) => {
475
+ //寫入於 tokens 表鎖內(S01); opt.recheckUser 為 true 時於鎖內重讀使用者, 已不存在、已停用或封鎖中即 reject 'failedLoginForCatch' (對外與密碼錯誤同回應, 不洩漏帳號狀態);
476
+ //另給 opt.verifiedUser(鎖外通過驗證所依據之使用者)時, 鎖內以新鮮資料再判登入條件: 密碼已變更 → 'failedLoginForCatch'、已不是已驗證 → 'userRegistrationNotVerified'、已到期 → 'loginAccountExpired',
477
+ //與鎖外同序同鍵; opt.withUser 為 true 時回傳 { token, user }(user 為鎖內新鮮之使用者, 僅 recheckUser 時有值), 否則回傳 token 字串
478
+ //userIdUpdate 記 token 擁有者(procOrm 以 userId 參數戳記, R-CONC-04)
479
+ let createToken = async (userId, opt = {}) => {
388
480
  let errTemp = null
389
481
 
390
482
  //check
@@ -407,13 +499,57 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
407
499
  //token
408
500
  let token = get(t, 'token', '')
409
501
 
410
- //insert
411
- await woItems.tokens.insert(t)
502
+ //insert, tokens 表鎖內
503
+ let rejectKey = null
504
+ let uFresh = null
505
+ await withTabLocks(['tokens'], async () => {
506
+
507
+ //recheckUser
508
+ if (get(opt, 'recheckUser') === true) {
509
+ let us = await woItems.users.select({ id: userId })
510
+ let u = get(us, 0, null)
511
+ let blocked = false
512
+ let timeBlocked = get(u, 'timeBlocked', '')
513
+ if (istimemsTZ(timeBlocked) && ot().format('YYYY-MM-DDTHH:mm:ss.SSSZ') <= timeBlocked) {
514
+ blocked = true
515
+ }
516
+ if (size(us) !== 1 || get(u, 'isActive', '') === 'n' || blocked) {
517
+ rejectKey = 'failedLoginForCatch'
518
+ return
519
+ }
520
+
521
+ //verifiedUser: 鎖外通過驗證所依據之使用者; 登入條件以鎖內新鮮資料再判一次(與 loginByAccountAndPassword 同序同鍵)
522
+ let vu = get(opt, 'verifiedUser', null)
523
+ if (vu !== null) {
524
+ if (get(u, 'password', '') !== get(vu, 'password', '')) {
525
+ rejectKey = 'failedLoginForCatch' //等待期間密碼已被變更(如管理員重設): 先前以舊密碼通過之驗證不再有效
526
+ return
527
+ }
528
+ if (!isestr(get(u, 'timeVerified', ''))) {
529
+ rejectKey = 'userRegistrationNotVerified'
530
+ return
531
+ }
532
+ let timeExpired = get(u, 'timeExpired', '')
533
+ if (isestr(timeExpired) && istimemsTZ(timeExpired) && ot().format('YYYY-MM-DDTHH:mm:ss.SSSZ') > timeExpired) {
534
+ rejectKey = 'loginAccountExpired'
535
+ return
536
+ }
537
+ }
538
+
539
+ uFresh = u
540
+ }
541
+
542
+ await procOrm(userId, 'tokens', 'insert', [t])
543
+
544
+ })
412
545
  .catch((err) => {
413
546
  errTemp = err
414
547
  })
415
548
 
416
549
  //check
550
+ if (rejectKey !== null) {
551
+ return Promise.reject(rejectKey)
552
+ }
417
553
  if (errTemp) {
418
554
  console.log(errTemp)
419
555
  // console.log(`token`, token)
@@ -421,6 +557,9 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
421
557
  return Promise.reject(`can not create a token from userId`)
422
558
  }
423
559
 
560
+ if (get(opt, 'withUser') === true) {
561
+ return { token, user: uFresh }
562
+ }
424
563
  return token
425
564
  }
426
565
 
@@ -616,6 +755,7 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
616
755
 
617
756
 
618
757
  //refreshToken
758
+ //讀取、驗證與續期寫入皆在 tokens 表鎖內(S02): 讀到之 token 於寫入前不會被登出 / 撤銷插隊刪除, 且寫入為只改不增, 不致把已刪之 token 以殘缺列新增回庫 (X5)
619
759
  let refreshToken = async (token) => {
620
760
 
621
761
  //defense-in-depth NoSQL operator injection guard (reject key 名, 對齊 token 鏈統一 tokenExpired)
@@ -623,6 +763,13 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
623
763
  return Promise.reject('tokenExpired')
624
764
  }
625
765
 
766
+ return await withTabLocks(['tokens'], () => _refreshToken(token))
767
+ }
768
+
769
+
770
+ //_refreshToken, 須於 tokens 表鎖內呼叫
771
+ let _refreshToken = async (token) => {
772
+
626
773
  let errTemp = null
627
774
 
628
775
  //tks
@@ -677,15 +824,11 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
677
824
  let timeEndNew = ot().add(minExpired, 'minute').format('YYYY-MM-DDTHH:mm:ss.SSSZ')
678
825
  // console.log('timeEndNew', timeEndNew)
679
826
 
680
- //timeUpdate
681
- let timeUpdate = ot().format('YYYY-MM-DDTHH:mm:ss.SSSZ')
682
-
683
- //save, 逐筆檢查(ADR-080): 單筆失敗時 ORM 仍 resolve, 不檢查即回報未生效之新到期時間
684
- await ormWrite(woItems.tokens, 'save', {
827
+ //save, 經 procOrm 且 mustExist(只改不增, 列已不存在 → reject 'rowNotFound' 零寫入); procOrm 逐筆檢查(ADR-080)並戳記 timeUpdate / userIdUpdate(記 token 擁有者, R-CONC-04)
828
+ await procOrm(get(tk, 'userId', ''), 'tokens', 'save', [{
685
829
  id: tk.id,
686
830
  timeEnd: timeEndNew,
687
- timeUpdate,
688
- })
831
+ }], { mustExist: true })
689
832
  .catch((err) => {
690
833
  errTemp = err
691
834
  })
@@ -704,6 +847,7 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
704
847
 
705
848
 
706
849
  //logoutByToken
850
+ //讀取與刪除在 tokens 表鎖內(S03); 刪除未命中回報既有鍵 'can not delete the token'
707
851
  let logoutByToken = async (token) => {
708
852
 
709
853
  //defense-in-depth NoSQL operator injection guard (reject key 名, 對齊 token 鏈統一 tokenExpired)
@@ -711,6 +855,13 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
711
855
  return Promise.reject('tokenExpired')
712
856
  }
713
857
 
858
+ return await withTabLocks(['tokens'], () => _logoutByToken(token))
859
+ }
860
+
861
+
862
+ //_logoutByToken, 須於 tokens 表鎖內呼叫
863
+ let _logoutByToken = async (token) => {
864
+
714
865
  let errTemp = null
715
866
 
716
867
  //tks
@@ -849,7 +1000,9 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
849
1000
  let keyAccount = `createUser:account:${account}`
850
1001
  let keyEmail = `createUser:email:${email}`
851
1002
  let tokenVerify
852
- await cst.setWithFree([keyAccount, keyEmail], async () => {
1003
+ //唯一性檢查與 insert 於 users 表鎖內(S04, 修 X8): cst 之鍵空間與後台清單儲存(updateTabItems 取 users 表鎖)不同, 兩者原不互斥,
1004
+ //自助註冊與管理員新增同帳號可同時成功; 取同一把表鎖後, 後到者必能讀到先到者所寫之列而被唯一性檢查擋下
1005
+ await cst.setWithFree([keyAccount, keyEmail], () => withTabLocks(['users'], async () => {
853
1006
 
854
1007
  //check account unique (全域唯一,不限 isActive)
855
1008
  let allUsers = await woItems.users.select()
@@ -890,7 +1043,7 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
890
1043
  srLog.error({ event: 'fun-createUser-insert', key: 'userRegistrationFailed', account, err: getErrorMessage(err) })
891
1044
  return Promise.reject('userRegistrationFailed')
892
1045
  })
893
- })
1046
+ }))
894
1047
  .catch((err) => {
895
1048
  //remap setWithFree 之 'key in use' reject → 業務語意之 i18n 訊息.
896
1049
  //其他 reject (select unique check 之 'account/email already exists', insert 失敗之 userRegistrationFailed 等)
@@ -932,41 +1085,46 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
932
1085
  return Promise.reject('verifyEmailInvalidToken')
933
1086
  }
934
1087
 
935
- //以 tokenVerify 查找使用者
936
- let us = await woItems.users.select({ tokenVerify: token })
937
- if (size(us) === 0) {
938
- return Promise.reject('verifyEmailInvalidToken')
939
- }
940
- let user = us[0]
1088
+ //查找、檢查與寫入於 users 表鎖內(S05): timeVerified 為雙方欄位(後台可改、驗證信連結亦寫), 系統直寫須取表鎖, 後台持舊畫面存檔才會被偵測而拒收 (V4)
1089
+ return await withTabLocks(['users'], async () => {
941
1090
 
942
- //check timeVerified (是否已驗證)
943
- let timeVerified = get(user, 'timeVerified', '')
944
- if (isestr(timeVerified)) {
945
- return Promise.reject('verifyEmailAlreadyVerified')
946
- }
1091
+ //以 tokenVerify 查找使用者
1092
+ let us = await woItems.users.select({ tokenVerify: token })
1093
+ if (size(us) === 0) {
1094
+ return Promise.reject('verifyEmailInvalidToken')
1095
+ }
1096
+ let user = us[0]
947
1097
 
948
- //update timeVerified: timemsTZ 格式(同其他時間欄與後台編輯之寫入). 原 now2str 為秒級, 共用判斷 getIsVerified(istimemsTZ)
949
- //判為未驗證 → 後台清單顯示「未驗證」, 且該帳號設為管理者後過不了管理權限閘 funCheckAdmin (2026-09-29 修正)
950
- //逐筆檢查(ADR-080): 寫入失敗回 verifyEmailFailed, 不顯示驗證成功; tokenVerify 未清, 使用者可再點同一連結重試
951
- let errTemp = null
952
- await ormWrite(woItems.users, 'save', {
953
- id: user.id,
954
- timeVerified: nowms2str(),
955
- //F-053 fix (lazy clear): 不主動清 tokenVerify, 避免並發 resendVerifyEmail 寫的新 token
956
- //被此處 race window 覆蓋. token 重用風險由 line ~818 之 isestr(timeVerified) → reject
957
- //'verifyEmailAlreadyVerified' 擋住, 已驗證 user 點舊連結會得友善訊息.
958
- })
959
- .catch((err) => {
960
- errTemp = err
961
- })
1098
+ //check timeVerified (是否已驗證)
1099
+ let timeVerified = get(user, 'timeVerified', '')
1100
+ if (isestr(timeVerified)) {
1101
+ return Promise.reject('verifyEmailAlreadyVerified')
1102
+ }
962
1103
 
963
- //check
964
- if (errTemp !== null) {
965
- srLog.error({ event: 'fun-verifyEmail-save', key: 'verifyEmailFailed', userId: user.id, err: getErrorMessage(errTemp) })
966
- return Promise.reject('verifyEmailFailed')
967
- }
1104
+ //update timeVerified: timemsTZ 格式(同其他時間欄與後台編輯之寫入). 原 now2str 為秒級, 共用判斷 getIsVerified(istimemsTZ)
1105
+ //判為未驗證 → 後台清單顯示「未驗證」, 且該帳號設為管理者後過不了管理權限閘 funCheckAdmin (2026-09-29 修正)
1106
+ //經 procOrm 且 mustExist(只改不增, 列已不存在則拒絕而不新增殘缺列, X5), 更新者記該使用者本人 (R-CONC-04)
1107
+ //逐筆檢查(ADR-080): 寫入失敗回 verifyEmailFailed, 不顯示驗證成功; tokenVerify 未清, 使用者可再點同一連結重試
1108
+ let errTemp = null
1109
+ await procOrm(user.id, 'users', 'save', [{
1110
+ id: user.id,
1111
+ timeVerified: nowms2str(),
1112
+ //F-053 fix (lazy clear): 不主動清 tokenVerify, 避免並發 resendVerifyEmail 寫的新 token
1113
+ //被此處 race window 覆蓋. token 重用風險由 line ~818 之 isestr(timeVerified) → reject
1114
+ //'verifyEmailAlreadyVerified' 擋住, 已驗證 user 點舊連結會得友善訊息.
1115
+ }], { mustExist: true })
1116
+ .catch((err) => {
1117
+ errTemp = err
1118
+ })
968
1119
 
969
- return { state: 'success', msg: 'ok' }
1120
+ //check
1121
+ if (errTemp !== null) {
1122
+ srLog.error({ event: 'fun-verifyEmail-save', key: 'verifyEmailFailed', userId: user.id, err: getErrorMessage(errTemp) })
1123
+ return Promise.reject('verifyEmailFailed')
1124
+ }
1125
+
1126
+ return { state: 'success', msg: 'ok' }
1127
+ })
970
1128
  }
971
1129
 
972
1130
 
@@ -994,43 +1152,50 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
994
1152
  return Promise.reject('userRegistrationResendThrottled')
995
1153
  }
996
1154
 
997
- //getGenUserByAccount
998
- let u = null
999
- try {
1000
- u = await _getGenUserByKV('account', account)
1001
- }
1002
- catch (err) {}
1003
- if (!u) {
1004
- return Promise.reject('userRegistrationResendInvalidEmail') //不洩露帳號是否存在
1005
- }
1155
+ //查找、檢查與寫入於 users 表鎖內(S06; kmx 在外層、表鎖在內層): tokenVerify 雖屬伺服器專有欄位(規格 §4 不要求表鎖), 本套件統一取鎖並只改不增 (決策 D2),
1156
+ //使讀到之列於寫入前不被刪除插隊而以殘缺列新增回庫 (X5)
1157
+ let { userId, name, tokenVerify, errTemp } = await withTabLocks(['users'], async () => {
1006
1158
 
1007
- //check email match
1008
- let uEmail = get(u, 'email', '')
1009
- if (uEmail !== email) {
1010
- return Promise.reject('userRegistrationResendInvalidEmail')
1011
- }
1159
+ //getGenUserByAccount
1160
+ let u = null
1161
+ try {
1162
+ u = await _getGenUserByKV('account', account)
1163
+ }
1164
+ catch (err) {}
1165
+ if (!u) {
1166
+ return Promise.reject('userRegistrationResendInvalidEmail') //不洩露帳號是否存在
1167
+ }
1012
1168
 
1013
- //check timeVerified
1014
- let timeVerified = get(u, 'timeVerified', '')
1015
- if (isestr(timeVerified)) {
1016
- return Promise.reject('userRegistrationAlreadyVerified')
1017
- }
1169
+ //check email match
1170
+ let uEmail = get(u, 'email', '')
1171
+ if (uEmail !== email) {
1172
+ return Promise.reject('userRegistrationResendInvalidEmail')
1173
+ }
1018
1174
 
1019
- //userId
1020
- let userId = get(u, 'id', '')
1021
- let name = get(u, 'name', '')
1175
+ //check timeVerified
1176
+ let timeVerified = get(u, 'timeVerified', '')
1177
+ if (isestr(timeVerified)) {
1178
+ return Promise.reject('userRegistrationAlreadyVerified')
1179
+ }
1022
1180
 
1023
- //產生新 tokenVerify 並更新至 users
1024
- //逐筆檢查(ADR-080): 寫入失敗即 reject, 不記節流、不寄出連結無效之信
1025
- let tokenVerify = `${genIDSeq()}`
1026
- let errTemp = null
1027
- await ormWrite(woItems.users, 'save', {
1028
- id: userId,
1029
- tokenVerify,
1181
+ //userId
1182
+ let userId = get(u, 'id', '')
1183
+ let name = get(u, 'name', '')
1184
+
1185
+ //產生新 tokenVerify 並更新至 users, 更新者記該使用者 (R-CONC-04)
1186
+ //逐筆檢查(ADR-080): 寫入失敗即 reject, 不記節流、不寄出連結無效之信
1187
+ let tokenVerify = `${genIDSeq()}`
1188
+ let errTemp = null
1189
+ await procOrm(userId, 'users', 'save', [{
1190
+ id: userId,
1191
+ tokenVerify,
1192
+ }], { mustExist: true })
1193
+ .catch((err) => {
1194
+ errTemp = err
1195
+ })
1196
+
1197
+ return { userId, name, tokenVerify, errTemp }
1030
1198
  })
1031
- .catch((err) => {
1032
- errTemp = err
1033
- })
1034
1199
 
1035
1200
  //check
1036
1201
  if (errTemp !== null) {
@@ -1147,55 +1312,62 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1147
1312
  //'incorrect old password' reject 路徑, 自然不會多寄信.
1148
1313
  return await kmx(`changeUserPassword:${userId}`, async () => {
1149
1314
 
1150
- //getGenUserByUserId (with password)
1151
- let u = await _getGenUserByKV('id', userId, { deletePassword: false })
1315
+ //讀取、驗舊密碼與寫入於 users 表鎖內(S07; kmx 在外層、表鎖在內層): password 為伺服器專有欄位、isForceChangePw 為雙方欄位(系統直寫須取表鎖),
1316
+ //驗證所依之 DB 雜湊與寫入間不被其他寫入者(重設密碼、後台存檔)插隊; 只改不增, 列已被刪則拒絕而不新增殘缺列 (X5)
1317
+ let { u, email } = await withTabLocks(['users'], async () => {
1152
1318
 
1153
- //account
1154
- let account = get(u, 'account', '')
1319
+ //getGenUserByUserId (with password)
1320
+ let u = await _getGenUserByKV('id', userId, { deletePassword: false })
1155
1321
 
1156
- //check newPassword
1157
- let r = checkUserPassword(lang, newPassword, { account })
1158
- if (r.state === 'error') {
1159
- return Promise.reject(r.key)
1160
- }
1322
+ //account
1323
+ let account = get(u, 'account', '')
1161
1324
 
1162
- //passwordTrue
1163
- let passwordTrue = get(u, 'password', '')
1325
+ //check newPassword
1326
+ let r = checkUserPassword(lang, newPassword, { account })
1327
+ if (r.state === 'error') {
1328
+ return Promise.reject(r.key)
1329
+ }
1164
1330
 
1165
- //check (verifyPassword: 以明文舊密碼 + DB 自描述雜湊字串 + pepper(salt) 驗證)
1166
- if (!verifyPassword(oldPassword, passwordTrue, salt)) {
1167
- return Promise.reject('userChangePasswordIncorrectOld')
1168
- }
1331
+ //passwordTrue
1332
+ let passwordTrue = get(u, 'password', '')
1169
1333
 
1170
- //email
1171
- let email = get(u, 'email', '')
1172
- if (!isestr(email)) {
1173
- // console.log('token', token)
1174
- // console.log('u', u)
1175
- return Promise.reject('anUnexpectedErrorOccurred')
1176
- }
1177
- // console.log('email', email)
1334
+ //check (verifyPassword: 以明文舊密碼 + DB 自描述雜湊字串 + pepper(salt) 驗證)
1335
+ if (!verifyPassword(oldPassword, passwordTrue, salt)) {
1336
+ return Promise.reject('userChangePasswordIncorrectOld')
1337
+ }
1178
1338
 
1179
- //hash newPassword
1180
- let passwordNew = hashPassword(newPassword, salt)
1339
+ //email
1340
+ let email = get(u, 'email', '')
1341
+ if (!isestr(email)) {
1342
+ // console.log('token', token)
1343
+ // console.log('u', u)
1344
+ return Promise.reject('anUnexpectedErrorOccurred')
1345
+ }
1346
+ // console.log('email', email)
1347
+
1348
+ //hash newPassword
1349
+ let passwordNew = hashPassword(newPassword, salt)
1350
+
1351
+ //save (清 isForceChangePw='n', 變更密碼成功後即解除強制變更), 更新者記該使用者本人 (R-CONC-04)
1352
+ //逐筆檢查(ADR-080): 寫入失敗即 reject, 不寄「密碼已變更」信
1353
+ let errTemp = null
1354
+ await procOrm(userId, 'users', 'save', [{
1355
+ id: userId,
1356
+ password: passwordNew,
1357
+ isForceChangePw: 'n',
1358
+ }], { mustExist: true })
1359
+ .catch((err) => {
1360
+ errTemp = err
1361
+ })
1181
1362
 
1182
- //save (清 isForceChangePw='n', 變更密碼成功後即解除強制變更)
1183
- //逐筆檢查(ADR-080): 寫入失敗即 reject, 不寄「密碼已變更」信
1184
- let errTemp = null
1185
- await ormWrite(woItems.users, 'save', {
1186
- id: userId,
1187
- password: passwordNew,
1188
- isForceChangePw: 'n',
1189
- })
1190
- .catch((err) => {
1191
- errTemp = err
1192
- })
1363
+ //check
1364
+ if (errTemp !== null) {
1365
+ srLog.error({ event: 'fun-changePassword-save', key: 'userChangePasswordFail', userId, err: getErrorMessage(errTemp) })
1366
+ return Promise.reject('userChangePasswordFail')
1367
+ }
1193
1368
 
1194
- //check
1195
- if (errTemp !== null) {
1196
- srLog.error({ event: 'fun-changePassword-save', key: 'userChangePasswordFail', userId, err: getErrorMessage(errTemp) })
1197
- return Promise.reject('userChangePasswordFail')
1198
- }
1369
+ return { u, email }
1370
+ })
1199
1371
 
1200
1372
  //若已變更密碼, 但寄送email失敗時, 不能報錯中斷流程
1201
1373
  try {
@@ -1296,43 +1468,50 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1296
1468
  return Promise.reject('adminResetPasswordAlreadyTriggered')
1297
1469
  }
1298
1470
 
1299
- //目標 user (含 password 拿到也不用, 只是確認存在)
1300
- let uTarget = null
1301
- try {
1302
- uTarget = await _getGenUserByKV('id', targetUserId, { deletePassword: true })
1303
- }
1304
- catch (err) {
1305
- return Promise.reject('adminResetPasswordUserNotFound')
1306
- }
1307
- if (!iseobj(uTarget)) {
1308
- return Promise.reject('adminResetPasswordUserNotFound')
1309
- }
1471
+ //目標 user 查找與寫入於 users 表鎖內(S08; kmx 在外層、表鎖在內層): password 為伺服器專有欄位、isForceChangePw 為雙方欄位(系統直寫須取表鎖);
1472
+ //只改不增, 目標已被刪則拒絕而不新增殘缺列 (X5); 更新者記操作管理員 (R-CONC-04)
1473
+ let { targetAccount, targetName, targetEmail, newPassword } = await withTabLocks(['users'], async () => {
1310
1474
 
1311
- let targetAccount = get(uTarget, 'account', '')
1312
- let targetName = get(uTarget, 'name', 'unknow')
1313
- let targetEmail = get(uTarget, 'email', '')
1475
+ //目標 user (含 password 拿到也不用, 只是確認存在)
1476
+ let uTarget = null
1477
+ try {
1478
+ uTarget = await _getGenUserByKV('id', targetUserId, { deletePassword: true })
1479
+ }
1480
+ catch (err) {
1481
+ return Promise.reject('adminResetPasswordUserNotFound')
1482
+ }
1483
+ if (!iseobj(uTarget)) {
1484
+ return Promise.reject('adminResetPasswordUserNotFound')
1485
+ }
1314
1486
 
1315
- //產生隨機新密碼 (符合 passwordPolicy + account 限制)
1316
- let newPassword = genRandomPassword(passwordPolicy, targetAccount)
1487
+ let targetAccount = get(uTarget, 'account', '')
1488
+ let targetName = get(uTarget, 'name', 'unknow')
1489
+ let targetEmail = get(uTarget, 'email', '')
1490
+
1491
+ //產生隨機新密碼 (符合 passwordPolicy + account 限制)
1492
+ let newPassword = genRandomPassword(passwordPolicy, targetAccount)
1493
+
1494
+ //hash + save (含 isForceChangePw='y')
1495
+ //逐筆檢查(ADR-080): 寫入失敗即 reject, 不記節流、不寄出未生效之新密碼
1496
+ let hashed = hashPassword(newPassword, salt)
1497
+ let errTemp = null
1498
+ await procOrm(operatorId, 'users', 'save', [{
1499
+ id: targetUserId,
1500
+ password: hashed,
1501
+ isForceChangePw: 'y',
1502
+ }], { mustExist: true })
1503
+ .catch((err) => {
1504
+ errTemp = err
1505
+ })
1317
1506
 
1318
- //hash + save (含 isForceChangePw='y')
1319
- //逐筆檢查(ADR-080): 寫入失敗即 reject, 不記節流、不寄出未生效之新密碼
1320
- let hashed = hashPassword(newPassword, salt)
1321
- let errTemp = null
1322
- await ormWrite(woItems.users, 'save', {
1323
- id: targetUserId,
1324
- password: hashed,
1325
- isForceChangePw: 'y',
1326
- })
1327
- .catch((err) => {
1328
- errTemp = err
1329
- })
1507
+ //check
1508
+ if (errTemp !== null) {
1509
+ srLog.error({ event: 'fun-adminResetUserPassword-save', key: 'adminResetPasswordFailed', token: maskToken(token), targetUserId, err: getErrorMessage(errTemp) })
1510
+ return Promise.reject('adminResetPasswordFailed')
1511
+ }
1330
1512
 
1331
- //check
1332
- if (errTemp !== null) {
1333
- srLog.error({ event: 'fun-adminResetUserPassword-save', key: 'adminResetPasswordFailed', token: maskToken(token), targetUserId, err: getErrorMessage(errTemp) })
1334
- return Promise.reject('adminResetPasswordFailed')
1335
- }
1513
+ return { targetAccount, targetName, targetEmail, newPassword }
1514
+ })
1336
1515
 
1337
1516
  //寫 throttle 時間戳 (放在 save 後, 確認此次 reset 已生效才記)
1338
1517
  lastResetTime.set(targetUserId, now)
@@ -1384,7 +1563,15 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1384
1563
  //revokeTokensByUserId: 撤銷(刪除)使用者名下全部 token, 供刪除 / 停用使用者(updateTabItems)與登入失敗封鎖(procProtect.blockAccount)共用.
1385
1564
  //一次刪除該使用者全部 token 並逐筆檢查(ormWrite), 單筆失敗不中斷其餘筆, 有失敗即 reject 錯誤訊息(含失敗筆主鍵)供呼叫端記錄.
1386
1565
  //冪等(已無 token 時刪 0 筆), resolve 回傳刪除之 token 數. 使用者設計上不持有 isApp='y' token, 故一併撤 (ADR-037).
1566
+ //S09: 對外之 revokeTokensByUserId 自取 tokens 表鎖; 上游已持 tokens 鎖者(updateTabItems users 路徑、procProtect 封鎖, 皆在 [tokens, users] 鎖內)
1567
+ //須呼叫 revokeTokensByUserIdLocked(持鎖版), 不可重入 (規格 §7)
1387
1568
  let revokeTokensByUserId = async (userId) => {
1569
+ return await withTabLocks(['tokens'], () => revokeTokensByUserIdLocked(userId))
1570
+ }
1571
+
1572
+
1573
+ //revokeTokensByUserIdLocked, 須於 tokens 表鎖內呼叫
1574
+ let revokeTokensByUserIdLocked = async (userId) => {
1388
1575
 
1389
1576
  //check
1390
1577
  if (!isestr(userId)) {
@@ -1409,6 +1596,14 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1409
1596
 
1410
1597
 
1411
1598
  //updateTabItems
1599
+ //後台清單整表存檔(users / tokens / ips), 多人同時編輯之寫入契約 (原則 10; 規格 dspec/多人同時編輯之寫入契約.md; 規則帳本 rulebook_concurrency R-CONC-01~05):
1600
+ //- 讀取、比對、驗證依賴庫內之項、diff、寫入、撤銷 token、清快取全程在該表表鎖內(users 為 [tokens, users], 因寫入後同鎖撤銷 token); 讀取為鎖內新鮮讀取(無 ORM 快取).
1601
+ //- 帶基準(opt.optClient, 前端以 buildBaseline 對畫面所見之列自算): 與鎖內新鮮讀取比對, 不符整次拒收(零寫入, 先動先贏), 回語系鍵 saveStale / saveRowNotFound / saveBaselineFormat 提示重新載入.
1602
+ // 刪除集合 = 基準內(且在範圍內) − 送出, 不刪載入後他人新增者; 修改 = 送出列之摘要 ≠ 基準, 只寫契約 fields(+ order), 伺服器專有欄位取庫內值, 摘要相同之列不寫(不戳記更新者).
1603
+ //- 不帶基準(app 權杖直呼、已開啟之舊版頁面; 本 major 允許, 後者蓋前者, 下一 major 後台必帶): 仍取鎖、只改不增、伺服器專有欄位不採用客戶端值.
1604
+ // users / ips 刪除集合 = 庫內全部 − 送出(以外部為主); tokens 只處理應用系統金鑰列(isApp === 'y'), session 列不刪、不覆蓋、不復活.
1605
+ //- tokens 採選項 P (R-CONC-03): app 列(契約 tokens)嚴格整表比對; session 列(契約 tokensSession)逐列寬鬆比對:
1606
+ // 只比對管理員有修改或刪除之列; 載入後新增者不刪; 已不在庫內者不復活; 系統續期而管理員未動者不寫; 管理員有動且庫內已變者拒收.
1412
1607
  //分驗證與寫入兩階段 (ADR-080):
1413
1608
  //- 驗證階段: 全部驗證於任何寫入之前完成, 任一失敗即 reject 該驗證之 key, 資料庫無任何變更 (ADR-021).
1414
1609
  // 凡可於寫入前判定、寫入時才會被拒或被靜默改寫之輸入一律於此拒絕 (ADR-081): rows 非陣列、主鍵非字串 / 重複 / 超過鍵長上限 → 'invalidRows';
@@ -1419,6 +1614,13 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1419
1614
  let updateTabItems = async (woName, rows, keyDetect, opt = {}) => {
1420
1615
  // console.log('updateTabItems', woName, rows.length, keyDetect)
1421
1616
 
1617
+ //contract: 寫入契約(src/writeContracts, 前後端同一來源); tokens 另有 session 列契約
1618
+ let contract = contracts[woName]
1619
+ let contractSession = contracts.tokensSession
1620
+ if (!iseobj(contract) || keyDetect !== contract.pk) {
1621
+ throw new Error(`invalid woName[${woName}] or keyDetect[${keyDetect}] of updateTabItems`)
1622
+ }
1623
+
1422
1624
  //resetOrder
1423
1625
  let resetOrder = get(opt, 'resetOrder')
1424
1626
  if (!isbol(resetOrder)) {
@@ -1437,6 +1639,9 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1437
1639
  //ocList: 該清單之讀取快取 (wsemi cache), 於寫入階段後清除 (ADR-008, ADR-080); 未給則不清
1438
1640
  let ocList = get(opt, 'ocList', null)
1439
1641
 
1642
+ //optClient: 前端經 RPC 送入之第 4 參數(未驗證), 於下方 parseSaveOpt 驗證後自組基準; 不展開給 procOrm (規格 §6)
1643
+ let optClient = get(opt, 'optClient')
1644
+
1440
1645
  //check, rows 須為陣列 (ADR-081): 非陣列經 ltdtmapping 成空陣列, 比對時舊表全數歸為刪除
1441
1646
  if (!isarr(rows)) {
1442
1647
  return Promise.reject('invalidRows')
@@ -1444,11 +1649,16 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1444
1649
 
1445
1650
  //idsNew: 前端標為新增(transient 欄位 _isNew === true)之列 id, 供下方比對既有列 (ADR-074);
1446
1651
  //_isNew 不在 schema keys 內, 下方 ltdtmapping 取欄時即剝除不入庫
1652
+ //permsRaw: tokens 之 perms 於 ltdtmapping 前取原值(缺欄為 undefined), 供鎖內驗證 (ADR-069; ltdtmapping 會把缺欄補成 '')
1447
1653
  let idsNew = []
1654
+ let permsRaw = new Map()
1448
1655
  each(rows, (r) => {
1449
1656
  if (get(r, '_isNew') === true) {
1450
1657
  idsNew.push(get(r, 'id', ''))
1451
1658
  }
1659
+ if (woName === 'tokens') {
1660
+ permsRaw.set(get(r, 'id', ''), get(r, 'perms'))
1661
+ }
1452
1662
  })
1453
1663
 
1454
1664
  //ltdtmapping
@@ -1508,223 +1718,428 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1508
1718
  }
1509
1719
  }
1510
1720
 
1511
- //ltdtDiffByKey
1512
- let ltdtOld = await woItems[woName].select()
1513
- //console.log(`...woName[${woName}].select`)
1514
-
1515
- //新增列已存在即拒絕 (ADR-074): 同一包重送且第 2 次於第 1 次完成後才處理時, 第 1 次已建立之列會被當「修改」,
1516
- //以前端之空值 / 佔位字覆寫伺服器於新增時填入之欄位(實測: 新使用者 timeVerified 被清空、建立者 / 建立時間寫成佔位字而無法登入)
1517
- if (size(idsNew) > 0) {
1518
- let kpIdOld = Object.create(null)
1519
- each(ltdtOld, (r) => {
1520
- kpIdOld[get(r, 'id', '')] = true
1521
- })
1522
- if (idsNew.some((id) => kpIdOld[id] === true)) {
1523
- return Promise.reject('saveNewRowExists')
1524
- }
1525
- }
1526
-
1527
- //users 路徑: account 唯一性 + 非空驗證 (補 id/email 之外的 account; 防直打 API 寫入空/重複帳號).
1528
- //(a) 空值 → reject 'accountRequired'; (b) 本批內重複 或 與既有其他 user 之 account 衝突 → reject 'accountDuplicate'.
1529
- //帳號全域唯一, 與既有比對時排除「本批同 id 之自身既有列」(更新自己時 account 可維持不變).
1530
- if (woName === 'users') {
1531
- //既有 account → id 對照 (排除自身用)
1532
- let accountToIdOld = Object.create(null)
1533
- each(ltdtOld, (r) => {
1534
- let a = get(r, 'account', '')
1535
- if (isestr(a)) {
1536
- accountToIdOld[a] = get(r, 'id', '')
1537
- }
1538
- })
1539
- let kpAccount = Object.create(null)
1540
- let errAccount = null
1541
- each(rows, (row) => {
1542
- let account = get(row, 'account', '')
1543
- let id = get(row, 'id', '')
1544
- //空值
1545
- if (!isestr(account)) {
1546
- errAccount = 'accountRequired'
1547
- return false //跳出
1548
- }
1549
- //本批內重複
1550
- if (haskey(kpAccount, account)) {
1551
- errAccount = 'accountDuplicate'
1552
- return false //跳出
1553
- }
1554
- //與既有其他 user (非自身) 衝突
1555
- if (haskey(accountToIdOld, account) && accountToIdOld[account] !== id) {
1556
- errAccount = 'accountDuplicate'
1557
- return false //跳出
1558
- }
1559
- kpAccount[account] = true
1560
- })
1561
- if (errAccount !== null) {
1562
- return Promise.reject(errAccount)
1563
- }
1721
+ //parseSaveOpt: 驗證前端送入之基準(格式或版本不識 → baselineFormat, 不得降級為不帶基準); 於取鎖、讀庫之前
1722
+ let po = parseSaveOpt(woName, optClient)
1723
+ if (po.err !== undefined) {
1724
+ return Promise.reject(kpSaveRejectKey[po.err])
1564
1725
  }
1726
+ let isBased = po.baseline !== undefined
1565
1727
 
1566
- //users 路徑: 既有 row 的 password 保留 DB hash, 避免前端送來的 '' (getUsersList 已 strip)
1567
- //在儲存階段把 DB 既有 password 覆蓋成空字串, 等同把所有既有使用者密碼洗掉
1568
- if (woName === 'users') {
1569
- let oldById = Object.create(null)
1570
- each(ltdtOld, (r) => {
1571
- oldById[r.id] = r
1572
- })
1728
+ //subRaw / subNorm: 送出之列(依 id); subRaw 為前端所見形式(供與基準比對摘要), subNorm 為寫入形式(tokens 之 perms 去重並依全集排序, ADR-069, 於鎖內驗證合法後才正規化)
1729
+ let subRaw = new Map()
1730
+ each(rows, (row) => {
1731
+ subRaw.set(row.id, row)
1732
+ })
1733
+ let subNorm = subRaw
1734
+ if (woName === 'tokens') {
1735
+ subNorm = new Map()
1573
1736
  each(rows, (row) => {
1574
- let oldRow = oldById[row.id]
1575
- if (oldRow) {
1576
- row.password = oldRow.password
1577
- }
1737
+ subNorm.set(row.id, { ...row, perms: normAppPerms(permsRaw.get(row.id)) })
1578
1738
  })
1579
1739
  }
1580
-
1581
1740
  let ltdtNew = rows
1582
- let r = ltdtDiffByKey(ltdtOld, ltdtNew, keyDetect)
1583
- // console.log('ltdtDiffByKey r', r)
1584
-
1585
- //新增或修改列之任一欄位值(含主鍵; password 此時為明文、其後雜湊, 不檢查)會被 clearXSS 改寫即拒絕, 屬驗證階段 (ADR-021, ADR-081):
1586
- //procOrm 寫入時對各欄位 clearXSS(w-serv-orm getMapOrm 之 adjustData), 主鍵會被改寫者 1.1.8 起整批拒絕(屆時同批之刪除已落地),
1587
- //其餘欄位則被靜默改寫, 使儲存值與送入值不同(account / email 之唯一性與登入即以送入值比對).
1588
- //刪除不經 clearXSS、比對後屬 same 之列不寫入, 皆不檢查; 惟 users 以 resetOrder 依位置重編 order, 既有列只要位置變動即屬 diff 而受檢
1589
- let rowsMarkup = filter([...r.add, ...r.diff], (row) => {
1590
- return keys(row).some((k) => k !== 'password' && !isEqual(clearXSS(row[k]), row[k]))
1591
- })
1592
- if (size(rowsMarkup) > 0) {
1593
- return Promise.reject('htmlMarkupNotAllowed')
1741
+ if (woName === 'tokens') {
1742
+ ltdtNew = map(rows, (row) => subNorm.get(row.id))
1594
1743
  }
1595
1744
 
1596
- //users 路徑: 對新使用者的明文密碼進行 policy 檢查與 hash, 屬驗證階段, 須於任何寫入之前 (ADR-021, ADR-080;
1597
- //原置於刪除寫入之後, 密碼不符策略時刪除與撤銷已落地卻回驗證錯誤)
1598
- if (woName === 'users' && size(r.add) > 0) {
1599
- for (let row of r.add) {
1600
- let pw = get(row, 'password', '')
1601
- let account = get(row, 'account', '')
1602
- let chk = checkUserPassword(lang, pw, { account })
1603
- if (chk.state === 'error') {
1604
- return Promise.reject(chk.key)
1605
- }
1606
- row.password = hashPassword(pw, salt)
1607
- }
1608
- //後台建帳自動填 timeVerified: timemsTZ 格式(見 verifyEmail 同處說明; 原 now2str 秒級使新建帳號顯示未驗證、設為管理者後過不了 funCheckAdmin, 2026-09-29 修正)
1609
- each(r.add, (row) => {
1610
- let tv = get(row, 'timeVerified', '')
1611
- if (!isestr(tv)) {
1612
- row.timeVerified = nowms2str()
1613
- }
1614
- })
1615
- }
1745
+ //表鎖: users 寫入後須同鎖撤銷其 token, 故為 [tokens, users] (withTabLocks 依表名碼元序取鎖, 不死結)
1746
+ let tables = woName === 'users' ? ['tokens', 'users'] : [woName]
1616
1747
 
1617
- //rowsClear: 陣列欄變短之列先清空該欄: 儲存層 save 以 lodash merge 合併新舊列, 陣列逐索引合併, 新陣列較短時舊陣列尾端元素會殘留
1618
- //(如 tokens.perms 由 ['readTokens'] 撤銷為 [] 將無效, ADR-069). 先存 null 令該欄不再為陣列, 其後整列存入即為新陣列.
1619
- //兩次寫入之間該欄短暫為 null, tokens.perms 讀取端視同未設定(僅基本權限), 屬 fail closed 方向.
1620
- let rowsClear = []
1621
- if (size(r.diff) > 0) {
1622
- let kpOld = Object.create(null)
1623
- each(ltdtOld, (v) => {
1624
- kpOld[get(v, keyDetect, '')] = v
1625
- })
1626
- each(r.diff, (row) => {
1627
- let id = get(row, keyDetect, '')
1628
- let clear = null
1629
- each(row, (v, k) => {
1630
- let vOld = get(kpOld, [id, k])
1631
- if (isarr(v) && isarr(vOld) && v.length < vOld.length) {
1632
- if (clear === null) {
1633
- clear = { [keyDetect]: id }
1748
+ //讀取 → 比對基準 → 驗證 → diff → 寫入 → 撤銷 → 清快取, 全在表鎖內(guardWrite: 取鎖 → 新鮮讀取 → compareBaseline → write); tokens 之 app 列嚴格整表比對於此, session 列寬鬆比對於 write 內
1749
+ try {
1750
+ await guardWrite({
1751
+ tables,
1752
+ read: () => woItems[woName].select(),
1753
+ baseline: po.baseline, //undefined 為不帶基準(仍取鎖與讀取)
1754
+ contract,
1755
+ write: async (ltdtOld) => {
1756
+ //console.log(`...woName[${woName}].select`)
1757
+
1758
+ //nowById: 鎖內新鮮讀取之列
1759
+ let nowById = new Map()
1760
+ each(ltdtOld, (r) => {
1761
+ nowById.set(get(r, 'id', ''), r)
1762
+ })
1763
+
1764
+ //新增列已存在即拒絕 (ADR-074): 同一包重送且第 2 次於第 1 次完成後才處理時, 第 1 次已建立之列會被當「修改」,
1765
+ //以前端之空值 / 佔位字覆寫伺服器於新增時填入之欄位(實測: 新使用者 timeVerified 被清空、建立者 / 建立時間寫成佔位字而無法登入)
1766
+ if (size(idsNew) > 0 && idsNew.some((id) => nowById.has(id))) {
1767
+ return Promise.reject('saveNewRowExists')
1768
+ }
1769
+
1770
+ //tokens 路徑: 清單存檔不得新增庫內不存在之 session 列(isApp 非 'y'; ADR-092), 不論帶不帶基準, 整次拒收(零寫入, 回固定語系鍵, 不回顯值):
1771
+ //session 權杖只由登入產生, 後台清單本就無新增 session 列之入口 (ADR-042); 不帶基準時無從分辨「新增」與「載入後已被登出 / 自動封鎖撤銷之舊列」,
1772
+ //送回舊列會使其復活而繞過撤銷. 例外: 帶基準而該列在 baselineSession 內者(載入時尚在, 其後被撤銷)不屬新增——管理員未動者下方略過、已動者拒收 saveStale.
1773
+ //應用系統金鑰列(isApp === 'y')新增、既有 session 列之刪除與升級為應用系統金鑰照舊.
1774
+ if (woName === 'tokens') {
1775
+ let idsBase = new Set(isBased ? [...po.baseline, ...po.baselineSession].map(([id]) => id) : [])
1776
+ let rowSessionNew = rows.find((r) => get(r, 'isApp', '') !== 'y' && !nowById.has(r.id) && !idsBase.has(r.id))
1777
+ if (rowSessionNew !== undefined) {
1778
+ srLog.warn({ event: 'fun-updateTabItems-reject', key: 'tokenSessionAddNotAllowed', woName })
1779
+ return Promise.reject('tokenSessionAddNotAllowed')
1634
1780
  }
1635
- clear[k] = null
1636
1781
  }
1637
- })
1638
- if (clear !== null) {
1639
- rowsClear.push(clear)
1640
- }
1641
- })
1642
- }
1643
1782
 
1644
- //寫入階段: 刪除 → 新增 → 修改, 任一步 reject 即停止後續步驟 (不回滾, ADR-044)
1645
- let errWrite = null
1646
- try {
1783
+ //users 路徑: account 唯一性 + 非空驗證 (補 id/email 之外的 account; 防直打 API 寫入空/重複帳號).
1784
+ //(a) 空值 → reject 'accountRequired'; (b) 本批內重複 或 與既有其他 user 之 account 衝突 → reject 'accountDuplicate'.
1785
+ //帳號全域唯一, 與既有比對時排除「本批同 id 之自身既有列」(更新自己時 account 可維持不變).
1786
+ if (woName === 'users') {
1787
+ //既有 account → id 對照 (排除自身用)
1788
+ let accountToIdOld = Object.create(null)
1789
+ each(ltdtOld, (r) => {
1790
+ let a = get(r, 'account', '')
1791
+ if (isestr(a)) {
1792
+ accountToIdOld[a] = get(r, 'id', '')
1793
+ }
1794
+ })
1795
+ let kpAccount = Object.create(null)
1796
+ let errAccount = null
1797
+ each(rows, (row) => {
1798
+ let account = get(row, 'account', '')
1799
+ let id = get(row, 'id', '')
1800
+ //空值
1801
+ if (!isestr(account)) {
1802
+ errAccount = 'accountRequired'
1803
+ return false //跳出
1804
+ }
1805
+ //本批內重複
1806
+ if (haskey(kpAccount, account)) {
1807
+ errAccount = 'accountDuplicate'
1808
+ return false //跳出
1809
+ }
1810
+ //與既有其他 user (非自身) 衝突
1811
+ if (haskey(accountToIdOld, account) && accountToIdOld[account] !== id) {
1812
+ errAccount = 'accountDuplicate'
1813
+ return false //跳出
1814
+ }
1815
+ kpAccount[account] = true
1816
+ })
1817
+ if (errAccount !== null) {
1818
+ return Promise.reject(errAccount)
1819
+ }
1820
+ }
1647
1821
 
1648
- //del
1649
- if (size(r.del) > 0) {
1650
- await procOrm(operatorId, woName, 'del', r.del) //operatorId 用於 audit (userIdUpdate)
1651
- }
1822
+ //tokens 路徑: 本批有變動(或新增)之列須為未設定或已登錄權限字串之陣列, 任一列不合法則整批 reject 不寫入 (ADR-069).
1823
+ //與 DB 相同之列不驗: DB 內既有之毀損值(非陣列等)若也擋, 後台整批存回將永遠失敗而無從修正; 其正規化後之有效權限與讀取端 fail closed 結果相同.
1824
+ //帶基準時, 摘要同基準(管理員未動)之列亦不驗 (外審 sol#5): 該列不會被寫入——app 列已由嚴格整表比對確認庫內即基準, session 列未動不寫——
1825
+ //如舊資料毀損值之 session 於載入後已被登出(庫內已無此列), 不得因此阻擋其他列之合法修改; 管理員有動或新增之列仍照驗
1826
+ if (woName === 'tokens') {
1827
+ let dgBase = new Map(isBased ? [...po.baseline, ...po.baselineSession] : [])
1828
+ for (let row of rows) {
1829
+ let perms = permsRaw.get(row.id)
1830
+ let old = nowById.get(row.id)
1831
+ if (old !== undefined && JSON.stringify(get(old, 'perms')) === JSON.stringify(perms)) {
1832
+ continue
1833
+ }
1834
+ if (dgBase.has(row.id) && dgBase.get(row.id) === digestRow(subRaw.get(row.id), contract)) {
1835
+ continue
1836
+ }
1837
+ if (!isAppPermsValue(perms)) {
1838
+ return Promise.reject('tokenPermsInvalid')
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ //dels / adds / mods: 刪除(庫內之列)、新增(送出之列)、修改(送出之列)
1844
+ let dels = []
1845
+ let adds = []
1846
+ let mods = []
1847
+ let isApp = (r) => get(r, 'isApp', '') === 'y'
1848
+
1849
+ if (woName === 'tokens' && isBased) {
1850
+
1851
+ //app 列已由 guardWrite 嚴格整表比對相符(增、刪、改皆無差異); session 列(契約 tokensSession)逐列寬鬆比對,
1852
+ //只取 compareBaseline 之 changed(庫內摘要與基準不同者, 如續期改 timeEnd); added(載入後新增者)忽略; removed(已不在庫內者)以 nowById 判斷
1853
+ let baseA = new Map(po.baseline)
1854
+ let baseS = new Map(po.baselineSession)
1855
+ let cs = compareBaseline(ltdtOld, po.baselineSession, contractSession)
1856
+ let changedS = new Set(cs.ok ? [] : cs.changed)
1857
+
1858
+ //app 列: 基準內未送出 → 刪除; 送出之摘要 ≠ 基準 → 修改
1859
+ for (let [id, dg] of baseA) {
1860
+ let sub = subRaw.get(id)
1861
+ if (sub === undefined) {
1862
+ dels.push(nowById.get(id))
1863
+ }
1864
+ else if (digestRow(sub, contract) !== dg) {
1865
+ mods.push(subNorm.get(id))
1866
+ }
1867
+ }
1652
1868
 
1653
- //add
1654
- if (size(r.add) > 0) {
1655
- if (woName === 'tokens') {
1656
- //tokens 之 userId 為令牌擁有者, 而 procOrm(w-serv-orm getMapOrm 之 adjustData)於 insert 時把 userId 欄改寫為其 userId 參數(視為建立者):
1657
- //依擁有者分組並以擁有者為參數寫入, 使擁有者即送入值(原以本清單之操作者 '' 寫入, 鑄造之金鑰無擁有者而不可用; ADR-042, ADR-084).
1658
- //分組用 Map: 擁有者為任意字串, 一般物件會誤中原型鏈上之鍵(如 constructor)
1659
- let mpOwner = new Map()
1660
- each(r.add, (row) => {
1661
- let owner = get(row, 'userId', '')
1662
- if (!mpOwner.has(owner)) {
1663
- mpOwner.set(owner, [])
1869
+ //session 列
1870
+ for (let [id, dg] of baseS) {
1871
+ let sub = subRaw.get(id)
1872
+ let now = nowById.get(id)
1873
+ let live = now !== undefined && contractSession.scope(now)
1874
+ if (sub === undefined) {
1875
+ //管理員刪除: 已不在庫內(已被登出 / 撤銷)則冪等略過; 在庫內而庫內已變(如已續期)→ 管理員有動且庫內已變, 拒收
1876
+ if (!live) {
1877
+ continue
1878
+ }
1879
+ if (changedS.has(id)) {
1880
+ return Promise.reject('saveStale')
1881
+ }
1882
+ dels.push(now)
1883
+ continue
1884
+ }
1885
+ //管理員未動(摘要同基準): 不寫, 系統續期之 timeEnd 不被舊值蓋回, 已撤銷者不復活 (X1, X7)
1886
+ if (digestRow(sub, contract) === dg) {
1887
+ continue
1888
+ }
1889
+ //管理員有動: 已不在庫內或庫內已變 → 拒收; 否則修改(含升級為應用系統金鑰)
1890
+ if (!live || changedS.has(id)) {
1891
+ return Promise.reject('saveStale')
1892
+ }
1893
+ mods.push(subNorm.get(id))
1664
1894
  }
1665
- mpOwner.get(owner).push(row)
1895
+
1896
+ //基準之外之列(管理員新增): 已存在於庫內(他人載入後新增而撞 id)→ 拒收; 否則新增
1897
+ for (let [id] of subRaw) {
1898
+ if (baseA.has(id) || baseS.has(id)) {
1899
+ continue
1900
+ }
1901
+ if (nowById.has(id)) {
1902
+ return Promise.reject('saveStale')
1903
+ }
1904
+ adds.push(subNorm.get(id))
1905
+ }
1906
+
1907
+ }
1908
+ else if (woName === 'tokens') {
1909
+
1910
+ //不帶基準(app 權杖直呼、已開啟之舊版頁面): 刪除與修改只處理應用系統金鑰列(庫內 isApp === 'y',
1911
+ //修改另含送出 isApp === 'y' 者, 即升級為應用系統金鑰); session 列(登入新增、續期改 timeEnd、登出或封鎖刪除, 系統頻繁增刪)
1912
+ //不刪、不以送入值覆蓋, 載入後才登入之 session 因而不被誤刪 (E1, D3). 新增列(送出而庫內無此 id)照常新增,
1913
+ //含直打 API 鑄造使用者 token 與應用系統金鑰(ADR-042, ADR-084); 此路徑無基準可辨是否為已被撤銷之 session, 該風險由帶基準之後台頁(X1)承擔防護
1914
+ each(ltdtOld, (r) => {
1915
+ if (isApp(r) && !subRaw.has(r.id)) {
1916
+ dels.push(r)
1917
+ }
1918
+ })
1919
+ for (let [id, sub] of subNorm) {
1920
+ let old = nowById.get(id)
1921
+ if (old === undefined) {
1922
+ adds.push(sub)
1923
+ continue
1924
+ }
1925
+ if (!isApp(old) && !isApp(sub)) {
1926
+ continue
1927
+ }
1928
+ //以正規化後之列(perms 去重排序,缺欄 / 毀損值變 [])比對庫內: DB 既有之毀損值與無 perms 欄之舊列於存回時正規化為空陣列(ADR-069, PERM-A-005),
1929
+ //此為既有且有意之行為, 不以送入原值比對(外審 sol#8 之『原樣重送零寫入』與之衝突, 不採)
1930
+ if (digestRow(sub, contract) !== digestRow(old, contract)) {
1931
+ mods.push(sub)
1932
+ }
1933
+ }
1934
+
1935
+ }
1936
+ else {
1937
+
1938
+ //users / ips: 帶基準時 guardWrite 已嚴格比對相符(庫內 = 基準), 刪除集合 = 基準內 − 送出; 不帶基準時 = 庫內全部 − 送出
1939
+ if (isBased) {
1940
+ for (let [id] of po.baseline) {
1941
+ if (!subRaw.has(id) && nowById.has(id)) {
1942
+ dels.push(nowById.get(id))
1943
+ }
1944
+ }
1945
+ }
1946
+ else {
1947
+ each(ltdtOld, (r) => {
1948
+ if (!subRaw.has(r.id)) {
1949
+ dels.push(r)
1950
+ }
1951
+ })
1952
+ }
1953
+ //修改 = 送出列之摘要(契約 fields)≠ 庫內; 名次欄(users.order)值不同亦寫(存檔依列序重編)
1954
+ each(rows, (sub) => {
1955
+ let old = nowById.get(sub.id)
1956
+ if (old === undefined) {
1957
+ adds.push(sub)
1958
+ return
1959
+ }
1960
+ let changed = digestRow(sub, contract) !== digestRow(old, contract)
1961
+ if (!changed && contract.orderKey) {
1962
+ changed = !isEqual(get(old, contract.orderKey), get(sub, contract.orderKey))
1963
+ }
1964
+ if (changed) {
1965
+ mods.push(sub)
1966
+ }
1967
+ })
1968
+
1969
+ }
1970
+
1971
+ //rowsAdd: 新增列保留全欄形狀, 伺服器專有欄位(含稽核欄)不採用客戶端值, 由 procOrm 戳記或留空; users 之 password 為新增列例外(明文, 其後檢查策略並雜湊)
1972
+ let rowsAdd = map(adds, (sub) => {
1973
+ let row = { ...sub }
1974
+ each(contract.serverOnly, (k) => {
1975
+ row[k] = ''
1976
+ })
1977
+ if (woName === 'users') {
1978
+ row.password = get(sub, 'password', '')
1979
+ }
1980
+ return row
1981
+ })
1982
+
1983
+ //rowsMod: 修改列只寫契約 fields(+ 名次欄); 伺服器專有欄位(password、tokenVerify、稽核欄)取庫內值, 不入寫入列 (X2)
1984
+ let rowsMod = map(mods, (sub) => {
1985
+ let row = pickWriteRow(sub, contract)
1986
+ if (contract.orderKey) {
1987
+ row[contract.orderKey] = sub[contract.orderKey]
1988
+ }
1989
+ return row
1990
+ })
1991
+
1992
+ //新增或修改列之任一欄位值(含主鍵; password 此時為明文、其後雜湊, 不檢查)會被 clearXSS 改寫即拒絕, 屬驗證階段 (ADR-021, ADR-081):
1993
+ //procOrm 寫入時對各欄位 clearXSS(w-serv-orm getMapOrm 之 adjustData), 主鍵會被改寫者 1.1.8 起整批拒絕(屆時同批之刪除已落地),
1994
+ //其餘欄位則被靜默改寫, 使儲存值與送入值不同(account / email 之唯一性與登入即以送入值比對).
1995
+ //刪除不經 clearXSS、摘要相同之列不寫入, 皆不檢查; 惟 users 以 resetOrder 依位置重編 order, 既有列只要位置變動即屬修改而受檢
1996
+ let rowsMarkup = filter([...rowsAdd, ...rowsMod], (row) => {
1997
+ return keys(row).some((k) => k !== 'password' && !isEqual(clearXSS(row[k]), row[k]))
1666
1998
  })
1667
- for (let [owner, rowsOwner] of mpOwner) {
1668
- await procOrm(owner, woName, 'insert', rowsOwner)
1999
+ if (size(rowsMarkup) > 0) {
2000
+ return Promise.reject('htmlMarkupNotAllowed')
1669
2001
  }
1670
- }
1671
- else {
1672
- await procOrm(operatorId, woName, 'insert', r.add)
1673
- }
1674
- }
1675
2002
 
1676
- //diff
1677
- if (size(rowsClear) > 0) {
1678
- await procOrm(operatorId, woName, 'save', rowsClear)
1679
- }
1680
- if (size(r.diff) > 0) {
1681
- await procOrm(operatorId, woName, 'save', r.diff)
1682
- }
2003
+ //users 路徑: 對新使用者的明文密碼進行 policy 檢查與 hash, 屬驗證階段, 須於任何寫入之前 (ADR-021, ADR-080;
2004
+ //原置於刪除寫入之後, 密碼不符策略時刪除與撤銷已落地卻回驗證錯誤)
2005
+ if (woName === 'users' && size(rowsAdd) > 0) {
2006
+ for (let row of rowsAdd) {
2007
+ let pw = get(row, 'password', '')
2008
+ let account = get(row, 'account', '')
2009
+ let chk = checkUserPassword(lang, pw, { account })
2010
+ if (chk.state === 'error') {
2011
+ return Promise.reject(chk.key)
2012
+ }
2013
+ row.password = hashPassword(pw, salt)
2014
+ }
2015
+ //後台建帳自動填 timeVerified: timemsTZ 格式(見 verifyEmail 同處說明; 原 now2str 秒級使新建帳號顯示未驗證、設為管理者後過不了 funCheckAdmin, 2026-09-29 修正)
2016
+ each(rowsAdd, (row) => {
2017
+ let tv = get(row, 'timeVerified', '')
2018
+ if (!isestr(tv)) {
2019
+ row.timeVerified = nowms2str()
2020
+ }
2021
+ })
2022
+ }
2023
+
2024
+ //rowsClear: 陣列欄變短之列先清空該欄: 儲存層 save 以 lodash merge 合併新舊列, 陣列逐索引合併, 新陣列較短時舊陣列尾端元素會殘留
2025
+ //(如 tokens.perms 由 ['readTokens'] 撤銷為 [] 將無效, ADR-069). 先存 null 令該欄不再為陣列, 其後整列存入即為新陣列.
2026
+ //兩次寫入之間該欄短暫為 null, tokens.perms 讀取端視同未設定(僅基本權限), 屬 fail closed 方向.
2027
+ let rowsClear = []
2028
+ each(rowsMod, (row) => {
2029
+ let id = get(row, keyDetect, '')
2030
+ let clear = null
2031
+ each(row, (v, k) => {
2032
+ let vOld = get(nowById.get(id), k)
2033
+ if (isarr(v) && isarr(vOld) && v.length < vOld.length) {
2034
+ if (clear === null) {
2035
+ clear = { [keyDetect]: id }
2036
+ }
2037
+ clear[k] = null
2038
+ }
2039
+ })
2040
+ if (clear !== null) {
2041
+ rowsClear.push(clear)
2042
+ }
2043
+ })
2044
+
2045
+ //寫入階段: 刪除 → 新增 → 修改, 任一步 reject 即停止後續步驟 (不回滾, ADR-044); 同一表鎖內, 刪除與修改皆 mustExist(只改不增, 列已不存在 → rowNotFound 而不新增殘缺列)
2046
+ let errWrite = null
2047
+ try {
2048
+
2049
+ //del, operatorId 用於 audit (userIdUpdate)
2050
+ if (size(dels) > 0) {
2051
+ await procOrm(operatorId, woName, 'del', map(dels, (row) => ({ id: row.id })), { mustExist: true })
2052
+ }
2053
+
2054
+ //add
2055
+ if (size(rowsAdd) > 0) {
2056
+ if (woName === 'tokens') {
2057
+ //tokens 之 userId 為令牌擁有者(非建立者): keepUserId 保留列上非空之擁有者, userIdUpdate 記操作者 (ADR-042, ADR-084; w-serv-orm 1.1.17);
2058
+ //擁有者為空者以操作者補, 不再寫成無擁有者之金鑰
2059
+ await procOrm(operatorId, woName, 'insert', rowsAdd, { keepUserId: true })
2060
+ }
2061
+ else {
2062
+ await procOrm(operatorId, woName, 'insert', rowsAdd)
2063
+ }
2064
+ }
1683
2065
 
2066
+ //mod
2067
+ if (size(rowsClear) > 0) {
2068
+ await procOrm(operatorId, woName, 'save', rowsClear, { mustExist: true })
2069
+ }
2070
+ if (size(rowsMod) > 0) {
2071
+ await procOrm(operatorId, woName, 'save', rowsMod, { mustExist: true })
2072
+ }
2073
+
2074
+ }
2075
+ catch (err) {
2076
+ errWrite = err
2077
+ }
2078
+
2079
+ //users 路徑: 撤銷本批欲刪除與欲停用(isActive==='n')者名下全部 token, 避免 orphan token 仍可 checkToken/refreshToken 續命 (ADR-037).
2080
+ //撤銷範圍依本批意圖, 不依寫入成敗(寫入失敗之列亦撤, fail closed); 先寫入再撤銷: 刪除 / 停用落地後已無法登入, 不會再產生新 token.
2081
+ //停用者沿用整表掃描(本批表內 isActive==='n' 者皆撤, 冪等). 撤銷失敗僅 srLog.error 不阻斷主流程.
2082
+ //與寫入同在 [tokens, users] 鎖內, 故用持鎖版 revokeTokensByUserIdLocked (不可重入, R-CONC-01)
2083
+ if (woName === 'users') {
2084
+ for (let row of dels) {
2085
+ let userId = get(row, 'id', '')
2086
+ await revokeTokensByUserIdLocked(userId)
2087
+ .catch((err) => {
2088
+ srLog.error({ event: 'fun-updateTabItems-revokeTokenOnDelete', userId, err: getErrorMessage(err) })
2089
+ })
2090
+ }
2091
+ for (let row of rows) {
2092
+ if (get(row, 'isActive', '') !== 'n') {
2093
+ continue
2094
+ }
2095
+ let userId = get(row, 'id', '')
2096
+ await revokeTokensByUserIdLocked(userId)
2097
+ .catch((err) => {
2098
+ srLog.error({ event: 'fun-updateTabItems-revokeTokenOnDisable', userId, err: getErrorMessage(err) })
2099
+ })
2100
+ }
2101
+ }
2102
+
2103
+ //寫入後立即 invalidate 30s cache, 避免改完之統計頁顯示舊資料 (ADR-008, audit F-050); 寫入失敗時資料庫可能已部分變更, 一併清除 (ADR-080)
2104
+ if (ocList !== null) {
2105
+ ocList.clear('fun')
2106
+ }
2107
+
2108
+ //check, 寫入階段失敗一律回 saveWriteFail, procOrm 原文(含失敗筆主鍵)僅記 log (ADR-034, ADR-080)
2109
+ if (errWrite !== null) {
2110
+ srLog.error({ event: 'fun-updateTabItems-write', key: 'saveWriteFail', woName, err: getErrorMessage(errWrite) })
2111
+ return Promise.reject('saveWriteFail')
2112
+ }
2113
+
2114
+ },
2115
+ })
1684
2116
  }
1685
2117
  catch (err) {
1686
- errWrite = err
1687
- }
1688
2118
 
1689
- //users 路徑: 撤銷本批欲刪除與欲停用(isActive==='n')者名下全部 token, 避免 orphan token 仍可 checkToken/refreshToken 續命 (ADR-037).
1690
- //撤銷範圍依本批意圖, 不依寫入成敗(寫入失敗之列亦撤, fail closed); 先寫入再撤銷: 刪除 / 停用落地後已無法登入, 不會再產生新 token.
1691
- //停用者沿用整表掃描(本批表內 isActive==='n' 者皆撤, 冪等). 撤銷失敗僅 srLog.error 不阻斷主流程.
1692
- if (woName === 'users') {
1693
- for (let row of r.del) {
1694
- let userId = get(row, 'id', '')
1695
- await revokeTokensByUserId(userId)
1696
- .catch((err) => {
1697
- srLog.error({ event: 'fun-updateTabItems-revokeTokenOnDelete', userId, err: getErrorMessage(err) })
1698
- })
1699
- }
1700
- for (let row of ltdtNew) {
1701
- if (get(row, 'isActive', '') !== 'n') {
1702
- continue
2119
+ //契約拒收鍵(baselineMismatch / rowNotFound / baselineFormat)轉語系鍵, 提示重新載入; 取鍵一律用核心 getRejectKey
2120
+ let kSave = getSaveRejectKey(err)
2121
+ if (kSave !== null) {
2122
+ if (srLog && isfun(srLog.warn)) {
2123
+ srLog.warn({ event: 'fun-updateTabItems-reject', key: kSave, woName }) //原則 3: 後端錯誤 log error key
1703
2124
  }
1704
- let userId = get(row, 'id', '')
1705
- await revokeTokensByUserId(userId)
1706
- .catch((err) => {
1707
- srLog.error({ event: 'fun-updateTabItems-revokeTokenOnDisable', userId, err: getErrorMessage(err) })
1708
- })
2125
+ return Promise.reject(kSave)
1709
2126
  }
1710
- }
1711
2127
 
1712
- //寫入後立即 invalidate 30s cache, 避免改完之統計頁顯示舊資料 (ADR-008, audit F-050); 寫入失敗時資料庫可能已部分變更, 一併清除 (ADR-080)
1713
- if (ocList !== null) {
1714
- ocList.clear('fun')
1715
- }
2128
+ //rowsInvalid: 庫內讀出之列主鍵重複或缺漏, 屬伺服端資料錯誤(重新載入無法解決), 記 log 後回伺服端錯誤鍵
2129
+ if (getRejectKey(err) === 'rowsInvalid') {
2130
+ srLog.error({ event: 'fun-updateTabItems-rowsInvalid', key: 'anUnexpectedErrorOccurred', woName, err: getErrorMessage(err) })
2131
+ return Promise.reject('anUnexpectedErrorOccurred')
2132
+ }
1716
2133
 
1717
- //check, 寫入階段失敗一律回 saveWriteFail, procOrm 原文(含失敗筆主鍵)僅記 log (ADR-034, ADR-080)
1718
- if (errWrite !== null) {
1719
- srLog.error({ event: 'fun-updateTabItems-write', key: 'saveWriteFail', woName, err: getErrorMessage(errWrite) })
1720
- return Promise.reject('saveWriteFail')
2134
+ return Promise.reject(err)
1721
2135
  }
1722
2136
 
1723
- //users 路徑: 回傳前剝除 password 欄位, 避免明文/hash 經 API 洩漏
2137
+ //users 路徑: 回傳前剝除 password 與 tokenVerify 欄位, 避免明文/hash/驗證 token 經 API 洩漏
1724
2138
  if (woName === 'users') {
1725
2139
  ltdtNew = map(ltdtNew, (row) => {
1726
2140
  let copy = { ...row }
1727
2141
  delete copy.password
2142
+ delete copy.tokenVerify
1728
2143
  return copy
1729
2144
  })
1730
2145
  }
@@ -1938,9 +2353,11 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
1938
2353
  //console.log(`...users.select`)
1939
2354
 
1940
2355
  //delete password, 無錯誤取得後即先刪除, 避免調整程式時意外洩漏hash後密碼
2356
+ //tokenVerify (註冊驗證用 token, 憑證類) 同步收回 (T3, 原則 8): 契約生效後後台不再需要整列存回它 (伺服器專有欄位, 存檔不採用客戶端值, R-CONC-02)
1941
2357
  if (deletePassword) {
1942
2358
  us = map(us, (u) => {
1943
2359
  delete u.password
2360
+ delete u.tokenVerify
1944
2361
  return u
1945
2362
  })
1946
2363
  }
@@ -2001,7 +2418,8 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
2001
2418
 
2002
2419
 
2003
2420
  //checkTokenAndUpdateUsersList
2004
- let checkTokenAndUpdateUsersList = async (token, lang, rows, opt = {}) => {
2421
+ //optClient: 前端經 RPC 送入之第 4 參數 { baseline }(未驗證, 由 updateTabItems 內 parseSaveOpt 驗證; 不展開給 procOrm); 未給即不帶基準
2422
+ let checkTokenAndUpdateUsersList = async (token, lang, rows, opt = {}, optClient = undefined) => {
2005
2423
 
2006
2424
  //checkToken
2007
2425
  await checkToken(token, opt) //resolve僅回傳true, reject代表無效token或檢測token發生錯誤
@@ -2053,7 +2471,7 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
2053
2471
 
2054
2472
  //updateUsersList (帶 lang/operatorId 給下層用於 add 群組密碼策略檢查與 audit)
2055
2473
  //停用使用者即時撤銷其全部 token (ADR-037) 與清除使用者清單快取 (ADR-008), 皆於 updateTabItems 寫入階段後執行, 不論寫入成敗 (ADR-080)
2056
- rows = await updateUsersList(rows, { lang, operatorId })
2474
+ rows = await updateUsersList(rows, { lang, operatorId, optClient })
2057
2475
 
2058
2476
  return rows
2059
2477
  })
@@ -2098,43 +2516,30 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
2098
2516
 
2099
2517
 
2100
2518
  //updateTokensList
2101
- let updateTokensList = async (rows) => {
2519
+ //opt: { operatorId, optClient }, operatorId 為操作者 id(稽核 userIdUpdate, R-CONC-04), optClient 見 checkTokenAndUpdateUsersList
2520
+ let updateTokensList = async (rows, opt = {}) => {
2102
2521
 
2103
2522
  //rows 來自使用端, 須為陣列才可走訪 (同 checkTokenAndUpdateUsersList; 非陣列〔如帶 length 之物件〕原於下方 for…of 拋 TypeError, R-OBJ-ITER-01)
2104
2523
  if (!isarr(rows)) {
2105
2524
  return Promise.reject('invalidRows')
2106
2525
  }
2107
2526
 
2108
- //perms: 本批有變動(或新增)之列須為未設定或已登錄權限字串之陣列, 任一列不合法則整批 reject 不寫入 (ADR-069).
2109
- //與 DB 相同之列不驗: DB 內既有之毀損值(非陣列等)若也擋, 後台整批存回將永遠失敗而無從修正; 其正規化後之有效權限與讀取端 fail closed 結果相同.
2110
- let kpPermsOld = Object.create(null)
2111
- each(await woItems.tokens.select(), (t) => {
2112
- kpPermsOld[t.id] = JSON.stringify(get(t, 'perms'))
2113
- })
2114
- for (let r of rows) {
2115
- let perms = get(r, 'perms')
2116
- let id = get(r, 'id', '')
2117
- if (haskey(kpPermsOld, id) && kpPermsOld[id] === JSON.stringify(perms)) {
2118
- continue
2119
- }
2120
- if (!isAppPermsValue(perms)) {
2121
- return Promise.reject('tokenPermsInvalid')
2122
- }
2123
- }
2124
- //合法者去重並依全集排序後儲存
2125
- rows = map(rows, (r) => {
2126
- return { ...r, perms: normAppPerms(get(r, 'perms')) }
2127
- })
2128
-
2527
+ //perms 之驗證(本批有變動或新增之列須為未設定或已登錄權限字串之陣列, 任一列不合法則整批 reject 不寫入, ADR-069)與合法者去重並依全集排序,
2528
+ //須對照鎖內新鮮讀取之庫內值, 故移入 updateTabItems 之表鎖內(原於鎖外讀庫比對, 與寫入之間可被插隊)
2129
2529
  //updateTabItems, 清除權杖清單快取於其寫入階段後執行 (ADR-080)
2130
- rows = await updateTabItems('tokens', rows, 'id', { resetOrder: false, ocList: ocGetTokensList })
2530
+ rows = await updateTabItems('tokens', rows, 'id', {
2531
+ resetOrder: false,
2532
+ operatorId: get(opt, 'operatorId', ''),
2533
+ optClient: get(opt, 'optClient'),
2534
+ ocList: ocGetTokensList,
2535
+ })
2131
2536
 
2132
2537
  return rows
2133
2538
  }
2134
2539
 
2135
2540
 
2136
2541
  //checkTokenAndUpdateTokensList
2137
- let checkTokenAndUpdateTokensList = async (token, rows, opt = {}) => {
2542
+ let checkTokenAndUpdateTokensList = async (token, rows, opt = {}, optClient = undefined) => {
2138
2543
 
2139
2544
  //checkToken
2140
2545
  await checkToken(token, opt) //resolve僅回傳true, reject代表無效token或檢測token發生錯誤
@@ -2147,7 +2552,7 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
2147
2552
  return await lockSave('updateTokensList', operatorId, async () => {
2148
2553
 
2149
2554
  //updateTokensList, 清除權杖清單快取 (ADR-008) 於 updateTabItems 寫入階段後執行, 不論寫入成敗 (ADR-080)
2150
- rows = await updateTokensList(rows)
2555
+ rows = await updateTokensList(rows, { operatorId, optClient })
2151
2556
 
2152
2557
  return rows
2153
2558
  })
@@ -2187,17 +2592,23 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
2187
2592
 
2188
2593
 
2189
2594
  //updateIpsList
2190
- let updateIpsList = async (rows) => {
2595
+ //opt: { operatorId, optClient }, 同 updateTokensList
2596
+ let updateIpsList = async (rows, opt = {}) => {
2191
2597
 
2192
2598
  //updateTabItems, 清除 IP 清單快取於其寫入階段後執行 (ADR-080)
2193
- rows = await updateTabItems('ips', rows, 'id', { resetOrder: false, ocList: ocGetIpsList })
2599
+ rows = await updateTabItems('ips', rows, 'id', {
2600
+ resetOrder: false,
2601
+ operatorId: get(opt, 'operatorId', ''),
2602
+ optClient: get(opt, 'optClient'),
2603
+ ocList: ocGetIpsList,
2604
+ })
2194
2605
 
2195
2606
  return rows
2196
2607
  }
2197
2608
 
2198
2609
 
2199
2610
  //checkTokenAndUpdateIpsList
2200
- let checkTokenAndUpdateIpsList = async (token, rows, opt = {}) => {
2611
+ let checkTokenAndUpdateIpsList = async (token, rows, opt = {}, optClient = undefined) => {
2201
2612
 
2202
2613
  //checkToken
2203
2614
  await checkToken(token, opt) //resolve僅回傳true, reject代表無效token或檢測token發生錯誤
@@ -2210,7 +2621,7 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
2210
2621
  return await lockSave('updateIpsList', operatorId, async () => {
2211
2622
 
2212
2623
  //updateIpsList, 清除 IP 清單快取 (ADR-008) 於 updateTabItems 寫入階段後執行, 不論寫入成敗 (ADR-080)
2213
- rows = await updateIpsList(rows)
2624
+ rows = await updateIpsList(rows, { operatorId, optClient })
2214
2625
 
2215
2626
  return rows
2216
2627
  })
@@ -2256,6 +2667,7 @@ function proc(woItems, procOrm, { srLog, srEmail, salt, minExpired, kpLang, pass
2256
2667
  getIpByKV,
2257
2668
 
2258
2669
  revokeTokensByUserId,
2670
+ revokeTokensByUserIdLocked, //持鎖版: 呼叫端須已持有 tokens 表鎖(procProtect 封鎖於 [tokens, users] 鎖內使用), 不可重入
2259
2671
 
2260
2672
  getGenUserByKV,
2261
2673
  getGenUserByUserId,