w-web-perm 1.1.12 → 1.1.14

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 (149) hide show
  1. package/README.md +222 -123
  2. package/dist/css/app.f24c5e50.css +1 -0
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.033deb22.js +1 -0
  5. package/dist/js/app.033deb22.js +1 -0
  6. package/dist/js/{chunk-vendors.3e9e3ef1.js → chunk-vendors-legacy.b0bbb5ac.js} +4 -4
  7. package/dist/js/{chunk-vendors-legacy.3e9e3ef1.js → chunk-vendors.b0bbb5ac.js} +4 -4
  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 +91 -6
  11. package/docs/global.html +29525 -1424
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +155 -175
  14. package/docs/server_cascadeWrite.mjs.html +204 -0
  15. package/docs/server_dataChannel.mjs.html +3 -3
  16. package/docs/server_keyCheck.mjs.html +480 -0
  17. package/docs/server_lockSave.mjs.html +2 -2
  18. package/docs/server_maskLog.mjs.html +2 -2
  19. package/docs/server_procCore.mjs.html +383 -60
  20. package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
  21. package/docs/server_syncCore.mjs.html +533 -0
  22. package/docs/server_syncKeep.mjs.html +318 -0
  23. package/docs/server_tools_authRefsAudit.mjs.html +247 -0
  24. package/docs/server_tools_authRefsClean.mjs.html +335 -0
  25. package/docs/server_tools_fromTools.mjs.html +797 -0
  26. package/docs/server_tools_isMain.mjs.html +103 -0
  27. package/docs/server_tools_nameDupReport.mjs.html +313 -0
  28. package/docs/server_validateRows.mjs.html +2 -2
  29. package/docs/server_wrapInjected.mjs.html +3 -3
  30. package/docs/src_fetchJson.mjs.html +2 -2
  31. package/docs/src_getPermUserInfor.mjs.html +175 -0
  32. package/docs/src_js_applySaved.mjs.html +128 -0
  33. package/docs/src_js_cellEditing.mjs.html +163 -0
  34. package/docs/src_js_cvKpToArr.mjs.html +2 -2
  35. package/docs/src_js_cvKpToObj.mjs.html +44 -33
  36. package/docs/src_js_draftSync.mjs.html +218 -0
  37. package/docs/src_js_eachOwn.mjs.html +3 -3
  38. package/docs/src_js_editorRefRows.mjs.html +356 -0
  39. package/docs/src_js_externalRows.mjs.html +362 -0
  40. package/docs/src_js_externalUi.mjs.html +226 -0
  41. package/docs/src_js_findRefRows.mjs.html +107 -0
  42. package/docs/src_js_findTouchedDup.mjs.html +150 -0
  43. package/docs/src_js_fmtLossyMsg.mjs.html +2 -2
  44. package/docs/src_js_fromAudit.mjs.html +489 -0
  45. package/docs/src_js_getAddImpact.mjs.html +269 -0
  46. package/docs/src_js_getAuthWarn.mjs.html +463 -0
  47. package/docs/src_js_getCascadePreview.mjs.html +258 -0
  48. package/docs/src_js_getDelRefCount.mjs.html +210 -0
  49. package/docs/src_js_getEffMode.mjs.html +92 -0
  50. package/docs/src_js_getKpLossy.mjs.html +47 -52
  51. package/docs/src_js_getNameErrs.mjs.html +266 -0
  52. package/docs/src_js_getOwn.mjs.html +3 -3
  53. package/docs/src_js_getPropKey.mjs.html +109 -0
  54. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  55. package/docs/src_js_isAuthMode.mjs.html +4 -4
  56. package/docs/src_js_joinNames.mjs.html +2 -2
  57. package/docs/src_js_legendFit.mjs.html +2 -2
  58. package/docs/src_js_pairRowsToSnapshot.mjs.html +156 -0
  59. package/docs/src_js_refAudit.mjs.html +359 -0
  60. package/docs/src_js_refCascade.mjs.html +572 -0
  61. package/docs/src_js_refIdentity.mjs.html +500 -0
  62. package/docs/src_js_relDialogSend.mjs.html +199 -0
  63. package/docs/src_js_rowDigest.mjs.html +195 -0
  64. package/docs/src_plugins_dataReloadGuard.mjs.html +109 -0
  65. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  66. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  67. package/g_authRefsClean.mjs +79 -0
  68. package/g_fixFromType.mjs +92 -0
  69. package/g_initialData.mjs +3 -3
  70. package/g_provideGrups.mjs +1 -1
  71. package/g_providePemis.mjs +1 -1
  72. package/g_provideTargets.mjs +1 -1
  73. package/g_provideUsers.mjs +1 -1
  74. package/g_purgeFrom.mjs +127 -0
  75. package/package.json +2 -2
  76. package/server/WWebPerm.mjs +153 -173
  77. package/server/cascadeWrite.mjs +132 -0
  78. package/server/dataChannel.mjs +1 -1
  79. package/server/keyCheck.mjs +408 -0
  80. package/server/procCore.mjs +381 -58
  81. package/server/procLang.mjs +338 -3
  82. package/server/provideTabs.mjs +6 -4
  83. package/server/syncCore.mjs +461 -0
  84. package/server/syncKeep.mjs +246 -0
  85. package/server/tools/authRefsAudit.mjs +175 -0
  86. package/server/tools/authRefsClean.mjs +263 -0
  87. package/server/tools/fromTools.mjs +725 -0
  88. package/server/tools/isMain.mjs +31 -0
  89. package/server/tools/nameDupReport.mjs +241 -0
  90. package/server/wrapInjected.mjs +1 -1
  91. package/src/components/LayoutContentGrups.vue +445 -75
  92. package/src/components/LayoutContentPemis.vue +444 -75
  93. package/src/components/LayoutContentTargets.vue +292 -25
  94. package/src/components/LayoutContentUsers.vue +383 -97
  95. package/src/components/VeCgrups.vue +88 -129
  96. package/src/components/VeCpemis.vue +86 -129
  97. package/src/components/VeCrules.vue +15 -5
  98. package/src/components/VeGrupBlngUsers.vue +312 -91
  99. package/src/components/VePemiBlngGrups.vue +319 -101
  100. package/src/getPermUserInfor.mjs +10 -0
  101. package/src/js/applySaved.mjs +56 -0
  102. package/src/js/cellEditing.mjs +91 -0
  103. package/src/js/cvKpToObj.mjs +42 -31
  104. package/src/js/draftSync.mjs +146 -0
  105. package/src/js/eachOwn.mjs +1 -1
  106. package/src/js/editorRefRows.mjs +284 -0
  107. package/src/js/externalRows.mjs +290 -0
  108. package/src/js/externalUi.mjs +154 -0
  109. package/src/js/findRefRows.mjs +35 -0
  110. package/src/js/findTouchedDup.mjs +78 -0
  111. package/src/js/fromAudit.mjs +417 -0
  112. package/src/js/getAddImpact.mjs +197 -0
  113. package/src/js/getAuthWarn.mjs +391 -0
  114. package/src/js/getCascadePreview.mjs +186 -0
  115. package/src/js/getDelRefCount.mjs +138 -0
  116. package/src/js/getEffMode.mjs +20 -0
  117. package/src/js/getKpLossy.mjs +44 -49
  118. package/src/js/getNameErrs.mjs +194 -0
  119. package/src/js/getOwn.mjs +1 -1
  120. package/src/js/getPropKey.mjs +37 -0
  121. package/src/js/isAuthMode.mjs +2 -2
  122. package/src/js/pairRowsToSnapshot.mjs +84 -0
  123. package/src/js/refAudit.mjs +287 -0
  124. package/src/js/refCascade.mjs +500 -0
  125. package/src/js/refIdentity.mjs +428 -0
  126. package/src/js/relDialogSend.mjs +127 -0
  127. package/src/js/rowDigest.mjs +123 -0
  128. package/src/plugins/dataReloadGuard.mjs +37 -0
  129. package/src/plugins/mShare.mjs +144 -140
  130. package/src/plugins/mUI.mjs +3 -8
  131. package/src/schema/contracts/grups.mjs +8 -0
  132. package/src/schema/contracts/index.mjs +17 -0
  133. package/src/schema/contracts/pemis.mjs +8 -0
  134. package/src/schema/contracts/targets.mjs +8 -0
  135. package/src/schema/contracts/users.mjs +13 -0
  136. package/src/schema/tables/grups.mjs +4 -0
  137. package/src/schema/tables/pemis.mjs +4 -0
  138. package/src/schema/tables/targets.mjs +4 -0
  139. package/src/schema/tables/users.mjs +156 -152
  140. package/srv.mjs +19 -0
  141. package/dist/css/app.6918ec45.css +0 -1
  142. package/dist/js/app-legacy.7cb7e394.js +0 -1
  143. package/dist/js/app.7cb7e394.js +0 -1
  144. package/docs/src_js_getKeyOrderLost.mjs.html +0 -121
  145. package/docs/src_js_getKpWriteLost.mjs.html +0 -100
  146. package/docs/src_js_keepKeyOrder.mjs.html +0 -139
  147. package/src/js/getKeyOrderLost.mjs +0 -49
  148. package/src/js/getKpWriteLost.mjs +0 -28
  149. package/src/js/keepKeyOrder.mjs +0 -67
package/README.md CHANGED
@@ -1,123 +1,222 @@
1
- # w-web-perm
2
- A web service for permissions.
3
-
4
- ![language](https://img.shields.io/badge/language-JavaScript-orange.svg)
5
- [![npm version](http://img.shields.io/npm/v/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
6
- [![license](https://img.shields.io/npm/l/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
7
- [![npm download](https://img.shields.io/npm/dt/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
8
- [![npm download](https://img.shields.io/npm/dm/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
9
- [![jsdelivr download](https://img.shields.io/jsdelivr/npm/hm/w-web-perm.svg)](https://www.jsdelivr.com/package/npm/w-web-perm)
10
-
11
- ## Documentation
12
- To view documentation or get support, visit [docs](https://yuda-lyu.github.io/w-web-perm/WWebPerm.html).
13
-
14
- ## Installation
15
-
16
- ### Using npm(ES6 module):
17
- ```alias
18
- npm i w-web-perm
19
- ```
20
-
21
- #### Example for server:
22
- > **Link:** [[dev source code](https://github.com/yuda-lyu/w-web-perm/blob/master/srv.mjs)]
23
- ```alias
24
- import WOrm from 'w-orm-lmdb/src/WOrmLmdb.mjs'
25
- import WWebPerm from './server/WWebPerm.mjs'
26
- import getSettings from './g_getSettings.mjs'
27
-
28
-
29
- //st
30
- let st = getSettings()
31
-
32
- let url = st.dbUrl
33
- let db = st.dbName
34
- let opt = {
35
-
36
- serverPort: 11006,
37
- subfolder: '', //mperm
38
- urlRedirect: 'https://www.google.com/', //本機測試時得先編譯, 再瀏覽: http://localhost:11006/
39
-
40
- webName: {
41
- 'eng': 'Permission Service',
42
- 'cht': '權限管理系統',
43
- },
44
- webDescription: {
45
- 'eng': 'A web service package for user permissions and management targets.',
46
- 'cht': 'A web service package for user permissions and management targets.',
47
- },
48
- webLogo: 'data:image/svg+xml;base64,...',
49
-
50
- }
51
-
52
- let getUserByToken = async (token) => {
53
- // return {} //測試無法登入
54
- if (token === '{token-for-application}') { //提供外部應用系統作為存取使用者
55
- return {
56
- id: 'id-for-application',
57
- name: 'application',
58
- email: 'application@example.com',
59
- isAdmin: 'y',
60
- }
61
- }
62
- if (token === 'sys') { //開發階段w-ui-loginout自動給予browser使用者(且位於localhost)的token為sys
63
- return {
64
- id: 'id-for-admin',
65
- name: 'tester',
66
- email: 'admin@example.com', //mappingBy為email, 開發階段時會使用email找到所建置之使用者資料
67
- isAdmin: 'y',
68
- }
69
- }
70
- console.log('invalid token') //勿印權杖值
71
- console.log('於生產環境時得加入SSO等驗證token機制')
72
- return {}
73
- }
74
-
75
- let verifyClientUser = (user, from) => {
76
- console.log('verifyClientUser/user', user)
77
- // return false //測試無法登入
78
- console.log('於生產環境時得加入限制瀏覽器使用者身份機制')
79
- return user.isAdmin === 'y' //測試僅系統管理者使用
80
- }
81
-
82
- let verifyAppUser = (user, from) => {
83
- console.log('verifyAppUser/user', user)
84
- // return false //測試無法登入
85
- console.log('於生產環境時得加入限制應用程式使用者身份機制')
86
- return user.isAdmin === 'y' //測試僅系統管理者使用
87
- }
88
-
89
- //WWebPerm
90
- let instWWebPerm = WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser, opt)
91
-
92
- instWWebPerm.on('error', (err) => {
93
- console.log(err)
94
- })
95
- ```
96
-
97
- ## Upgrade Notes
98
-
99
- ### Unreleased: objects carrying a numeric `length` key, and non-array `rows` (2026-10-20)
100
- - **`rows` must be an array (behavior change)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` now reject a `rows` argument that is not an array (a string, number, `null`, `undefined`, a plain object, or an object carrying a numeric `length` key), or whose elements are not non-empty objects (`[null]`, `[{}]`, `[""]`, `[[]]`), with `saveRowFieldInvalid` and leave the table unchanged. Previously such input was treated as an empty table: the whole table was cleared and the call reported success (in the `modeEditUsers: 'for:grups'` mode it was silently ignored). An empty array `[]` is still accepted (it clears the table). The bundled front end and `/syncAndReplaceTabs` (which already required a non-empty array) are not affected.
101
- - **Objects are iterated by their own keys**: a field or key named `length` is an ordinary key everywhere user-supplied objects are walked (write validation, the `cgrups` / `cpemis` / `crules` JSON strings evaluated by `getPerm` / `getPermUserInfor`, and the list pages). Before, lodash treated such an object as an array-like of that length: real keys were skipped (for example a group listed next to `"length": 0` was ignored, so its rules evaluated to `n`) and a huge `length` made the call spin or run out of memory.
102
- - **`/syncAndReplaceTabs` rows must be non-empty objects (behavior change)**: every element of `rows` must be an object with at least one key. A push containing a string, number, `null`, array, boolean or an empty object `{}` is rejected as a whole with `invalidRows` before anything is deleted. Before, a non-object element made the call fail with an engine message (for example `Cannot create property 'from' on string 'abc'`) that was returned to the application, and an empty object in a multi-row push was written as a blank row with a generated id. A row that carries no schema field at all (for example `{"foo":1}`; it would map to an all-empty row) is rejected the same way.
103
- - **Authorization lookups use the exact id, not a lodash path (behavior change, tightening)**: `getUserRules` (used by `getPerm` / `getPermUserInfor`), the SDK `perm().active(name)` and the edit dialogs of user groups, group permissions and permission rules now look a target, group or permission up by its exact name as an own property. Before, a name containing `.` or `[` was resolved as a path: when only `A` was granted, targets `A.0` and `A[0]` evaluated to `y` (and `A.length` to `1`), and inherited names such as `toString` could be returned. Names that are themselves keys (for example a target called `A.0` granted by a rule named `A.0`, or one called `constructor` / `__proto__`) are granted as usual. In the admin session of `modeEditUsers: 'for:grups'`, a database id containing `.` or `[` can no longer be taken over by another row's nested field. The edit dialogs also keep an item named `__proto__` when saving (before, it was shown as enabled but silently dropped from the saved JSON).
104
- - **Object-form entries are named by their key (behavior change, tightening)**: in the `cgrups` / `cpemis` / `crules` JSON of the form `{"<name>": …}`, an inner `name` field no longer renames the entry (before, `{"A":{"name":"B","isActive":"y"}}` granted `B` while the edit dialogs showed `A`). The array form `[{"name":"A","isActive":"y"}]` is unchanged. The permission-rule edit dialog also reads object-form values (`{"A":{"isActive":"y"}}`) the same way as the server, so such a rule is no longer shown as disabled (and revoked on save).
105
- - **`/syncAndReplaceTabs` blank rows**: a row that carries no content outside the server-managed fields is rejected with `invalidRows`. Server-managed fields are `from`, `userId`, `timeCreate`, `userIdUpdate` and `timeUpdate` (the server overwrites them); a field value counts as content when it is a non-empty string, a finite number (`0` included) or a boolean (`""`, `null`, `[]` and `{}` do not).
106
- - **Field values must be scalar (behavior change)**: every field of a row written by `updateTargets`, `updatePemis`, `updateGrups`, `updateUsers` or `/syncAndReplaceTabs` must be a string, number, boolean, `null` or absent. An object, array or other non-scalar value is rejected as a whole before anything is deleted (`saveRowFieldInvalid` for the admin pages, `invalidRows` for `/syncAndReplaceTabs`; the existing rows stay unchanged). No table has an object-typed field: `cgrups`, `cpemis` and `crules` are JSON **strings**. Before, a nested string such as `{ "description": { "value": "<script>" } }` slipped past the write validation and was silently rewritten when stored.
107
- - **Edit dialogs decode authorization data exactly like the server (behavior change, tightening)**: `cgrups` / `cpemis` / `crules` may also be written in the array form `[{ "name": "A", "isActive": "y" }]` (an external application writing directly; `getPerm` / `getPermUserInfor` already honor it). The edit dialogs for user groups, group permissions and permission rules (and the two "belongs to" dialogs) used to look entries up by name, showed such entries as disabled, and saving revoked the authorization. They now decode both forms with the same function as the server, so what a dialog shows matches what is authorized, and saving keeps the authorization unchanged. Details: (1) in `cgrups` / `cpemis`, an entry with `isActive: "y"` but a missing or invalid `mode` (anything other than `"OR"` / `"AND"`) is not honored by the server; the dialogs now show it as disabled (before, object-form entries like that were shown as enabled and saving filled in `OR`, silently granting it); (2) in the object form `{ "<name>": { "name": …, … } }` the outer key is the name and an inner `name` is ignored, also in the "belongs to" dialogs (before, saving there re-pointed the grant to the inner name); (3) the three direct editors always save the object form `{ "<name>": … }`, the two "belongs to" dialogs rewrite the stored string only when its decoded content changes; (4) see the next two items for the cases the object form cannot hold (the same name active as both `AND` and `OR`) and for the order of `AND` entries.
108
- - **Saving keeps the order of the stored entries (behavior change, fix)**: `mergeRules` starts the `AND` combination from the first `AND` entry when there is no active `OR` entry, so the order of the keys in `cgrups` / `cpemis` decides the result. The edit dialogs used to rewrite the keys in the order of the group / permission table, so with two or more `AND` entries of different rule sets a save with no visible change could gain or lose a grant. Entries already stored now keep their relative order and new ones are appended; for the array form the order is that of the first active entry of each name. `crules` is order independent and is handled the same way for consistency.
109
- - **Data that the object form cannot hold is not rewritten (behavior change, fail-closed)**: when a `cgrups` / `cpemis` array (written directly by an external application) has the same name active as both `OR` and `AND`, or has two or more active `AND` names where the first `AND` name cannot keep its place in the object form (integer-like names such as `"1"` always sort first), the edit dialogs refuse to write it. A direct editor with changes, or a "belongs to" dialog with a change on such a row, is not saved and shows a fixed message naming the affected names; the stored string is left untouched, and a dialog opened and saved without changes returns the original string. The data has to be corrected at its source. Data written by the dialogs themselves (the object form) is never affected. The same refusal applies when a dialog would have to write an `AND` entry whose position JavaScript cannot keep: an integer-like name (for example a group called `1`) always sorts first in an object, so enabling it as `AND` in a dialog is refused when it would move ahead of the existing `AND` entries and change which one is evaluated first (as `OR`, or disabled, it saves normally).
110
- - **List pages decode like the server**: the user, group and permission lists count and name the groups, permissions and rules of an array-form `cgrups` / `cpemis` / `crules` the same way as the server (before, the user list used the array index as the group name and the permission list counted 0 rules). Display only; nothing is written.
111
- - **`/syncAndReplaceTabs` requires non-empty string names and ids (behavior change)**: for `grups` and `pemis` the `name` of every row, and for `targets` the `id` of every row, must be a non-empty string (as the admin pages already required). A missing value, an empty string, or a non-string (number, boolean, `null`) rejects the whole push with `invalidRows` before anything is deleted. Before, a group or permission with `name: ""` was stored and honored by `getPerm` / `getPermUserInfor`, but the edit dialogs treated it as invalid and removed it on the next save. `users` rows are not affected.
112
-
113
- ### Upgrading to w-serv-orm 1.1.11 / w-syslog 1.0.33 (2026-10-14)
114
- - **Numeric ids are stored as strings**: a numeric row id sent to `/syncAndReplaceTabs` (or to `updatePemis`, `updateGrups`, `updateUsers`) is stored as its string form (`123` is stored as `'123'`) and the success response lists it as a string. Before, the number was replaced by a generated id while the response still showed the number. A number and its string form are the same id; `NaN` and `Infinity` count as no id (a new id is generated). `updateTargets` still accepts string ids only and rejects other ids with `saveRowFieldInvalid`.
115
- - **Ids owned by another source are rejected**: if an id pushed to `/syncAndReplaceTabs` already belongs to another `from`, the whole push is rejected with `invalidRows` before anything is deleted (before, the row was skipped while the push still returned success). External applications must use ids that do not overlap with other sources.
116
- - **Logging**: with w-syslog 1.0.33, a log write failure no longer terminates the process (it is reported on stderr), and `WWebPerm` throws at construction when `opt.logFd` cannot be created.
117
- - **Bundled usage**: the statistics worker file is located on first use from the installed package (`server/staLogs/staLogsCore.shellWorker.mjs`), so `dist/w-web-perm.umd.js` and downstream bundles can run the statistics; the package must be installed with its `server` folder.
118
-
119
- ### Upgrading to w-serv-orm 1.1.10 / w-serv-hapi 1.1.6 (2026-10-14)
120
- - **Removed options**: `opt.useCheckUser`, `opt.getUserById` and `opt.useExcludeWhenNotAdmin` were removed together with w-serv-orm 1.1.9. Passing any value other than `undefined`, `null` or `false` now throws when constructing `WWebPerm` (the message comes from w-serv-orm `checkOptRemoved`) and is logged to srLog. Delete these keys; users of the data channel are authorized by `verifyConn` (`verifyClientUser`), no replacement option is needed.
121
- - **Data channel is select-only (behavior change)**: only `<table>.select` of `targets`, `pemis`, `grups` and `users` is exposed (`methodsExec: ['select']`, and `authorizeExec` allows select only). Calling raw `insert`, `save`, `del` or `delAll` now returns `invalid func[<table>.<method>]`. Clients that wrote tables directly must call `updateTargets`, `updatePemis`, `updateGrups` or `updateUsers`, which apply the save lock, serialization, validation and whole-table diff.
122
- - **Write validation**: rows to be written are validated before anything is deleted or written. A string value that XSS cleaning would rewrite (for example `<` followed by a letter) is rejected with `htmlMarkupNotAllowed`; an id longer than 1978 UTF-8 bytes is rejected with `saveRowFieldInvalid` (admin lists) or `invalidRows` (`syncAndReplaceTabs`). Unexpected database write failures return `saveWriteFail`; the ORM messages are written to srLog only.
123
- - **Process-level errors**: w-serv-hapi 1.1.6 no longer registers `unhandledRejection` or `uncaughtException` handlers, and `WWebPerm` does not register them either; the deployment decides its own process policy.
1
+ # w-web-perm
2
+ A web service for permissions.
3
+
4
+ ![language](https://img.shields.io/badge/language-JavaScript-orange.svg)
5
+ [![npm version](http://img.shields.io/npm/v/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
6
+ [![license](https://img.shields.io/npm/l/w-web-perm.svg?style=flat)](https://npmjs.org/package/w-web-perm)
7
+ [![npm download](https://img.shields.io/npm/dt/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
8
+ [![npm download](https://img.shields.io/npm/dm/w-web-perm.svg)](https://npmjs.org/package/w-web-perm)
9
+ [![jsdelivr download](https://img.shields.io/jsdelivr/npm/hm/w-web-perm.svg)](https://www.jsdelivr.com/package/npm/w-web-perm)
10
+
11
+ ## Documentation
12
+ To view documentation or get support, visit [docs](https://yuda-lyu.github.io/w-web-perm/WWebPerm.html).
13
+
14
+ ## Installation
15
+
16
+ ### Using npm(ES6 module):
17
+ ```alias
18
+ npm i w-web-perm
19
+ ```
20
+
21
+ #### Example for server:
22
+ > **Link:** [[dev source code](https://github.com/yuda-lyu/w-web-perm/blob/master/srv.mjs)]
23
+ ```alias
24
+ import WOrm from 'w-orm-lmdb/src/WOrmLmdb.mjs'
25
+ import WWebPerm from './server/WWebPerm.mjs'
26
+ import getSettings from './g_getSettings.mjs'
27
+
28
+
29
+ //st
30
+ let st = getSettings()
31
+
32
+ let url = st.dbUrl
33
+ let db = st.dbName
34
+ let opt = {
35
+
36
+ serverPort: 11006,
37
+ subfolder: '', //mperm
38
+ urlRedirect: 'https://www.google.com/', //本機測試時得先編譯, 再瀏覽: http://localhost:11006/
39
+
40
+ webName: {
41
+ 'eng': 'Permission Service',
42
+ 'cht': '權限管理系統',
43
+ },
44
+ webDescription: {
45
+ 'eng': 'A web service package for user permissions and management targets.',
46
+ 'cht': 'A web service package for user permissions and management targets.',
47
+ },
48
+ webLogo: 'data:image/svg+xml;base64,...',
49
+
50
+ }
51
+
52
+ let getUserByToken = async (token) => {
53
+ // return {} //測試無法登入
54
+ if (token === '{token-for-application}') { //提供外部應用系統作為存取使用者
55
+ return {
56
+ id: 'id-for-application',
57
+ name: 'application',
58
+ email: 'application@example.com',
59
+ isAdmin: 'y',
60
+ }
61
+ }
62
+ if (token === 'sys') { //開發階段w-ui-loginout自動給予browser使用者(且位於localhost)的token為sys
63
+ return {
64
+ id: 'id-for-admin',
65
+ name: 'tester',
66
+ email: 'admin@example.com', //mappingBy為email, 開發階段時會使用email找到所建置之使用者資料
67
+ isAdmin: 'y',
68
+ }
69
+ }
70
+ console.log('invalid token') //勿印權杖值
71
+ console.log('於生產環境時得加入SSO等驗證token機制')
72
+ return {}
73
+ }
74
+
75
+ let verifyClientUser = (user, from) => {
76
+ console.log('verifyClientUser/user', user)
77
+ // return false //測試無法登入
78
+ console.log('於生產環境時得加入限制瀏覽器使用者身份機制')
79
+ return user.isAdmin === 'y' //測試僅系統管理者使用
80
+ }
81
+
82
+ let verifyAppUser = (user, from) => {
83
+ console.log('verifyAppUser/user', user)
84
+ // return false //測試無法登入
85
+ console.log('於生產環境時得加入限制應用程式使用者身份機制')
86
+ return user.isAdmin === 'y' //測試僅系統管理者使用
87
+ }
88
+
89
+ //WWebPerm
90
+ let instWWebPerm = WWebPerm(WOrm, url, db, getUserByToken, verifyClientUser, verifyAppUser, opt)
91
+
92
+ instWWebPerm.on('error', (err) => {
93
+ console.log(err)
94
+ })
95
+ ```
96
+
97
+ ## Security: caller identity
98
+
99
+ > Caller-identity responsibility (cross-package principle `dspec/使用者身分驗證原則.md`): the frameworks (`w-converhp`, `w-serv-hapi`, `w-serv-webdata`, `w-serv-orm`) do not verify tokens; this package does, entirely. The connection gate (`verifyConn`) and the executing identity (`getUserIdByToken`) are both fully verified (valid token, user present and active in this system, deployer authorization); hapi `apis` routes verify themselves; functions check the role on their own; public entries are limited to the ones named.
100
+
101
+ - **Connection gate and executing identity (since 2026-10-23, perm-run03)**: a data-channel request carries two tokens: the `Authorization` header (checked by `verifyConn`) and the `__sysToken__` of the request body (looked up by `getUserIdByToken`, which decides the user a function runs as). Both now go through the same full check `getAndVerifyClientUser`: your `getUserByToken` returns the user, the user must exist in the `users` table of this system (looked up by `mappingBy`) with `isActive: 'y'`, and your `verifyClientUser(user, from)` must return `true`. If the body token fails any step the function is **not executed** (nothing is written; the response carries a fixed message key such as `cannotFindUserFromToken`, `cannotGetUserFromPerm` or `userNoPermission`; srLog `getUserIdByToken-error`, never the token). The two tokens may belong to different users; each is verified, the framework does not compare them, and the function runs as the body token's user. Before this release `getUserIdByToken` only asked `getUserByToken` for an id, so a body token that was not in the `users` table, was disabled, or that `verifyClientUser` would refuse was accepted as long as the header token was valid.
102
+ - **What `verifyClientUser` sees**: for the body token it is called with `from = 'main'` (the same `apiType` as the connection gate, the only channel on which the framework looks up the executing identity), so a policy that branches on `from` behaves the same for both. The id returned to the framework (and written to `userId` / `userIdUpdate`) is the id **of the user's row in the `users` table of this system**, not the id your `getUserByToken` reported; they are the same with the default `mappingBy: 'id'` for users created in the admin pages (and for external rows migrated from the old synchronization), and differ for users imported by `/syncAndReplaceTabs` after the upgrade (their `id` is generated; they are found by `srcKey`, see Upgrade Notes section 4) and whenever `mappingBy` is `'email'` or `'name'`.
103
+ - **Function-level role check (behavior change)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` now refuse a caller whose row in the `users` table is not active **and** does not have `isAdmin: 'y'`, with the fixed message key `userNotAdmin`, before anything is read or written (srLog `requireAdmin-error`: function name and user id only). The `users` table of this system is the authority; the `isAdmin` that your `getUserByToken` reports is not used for this check (as for `isAdmin` elsewhere). Until now these functions relied entirely on your `verifyClientUser`; if it let non-administrators in (a read-only viewer, for example), they could write. The read-only functions (`getWebInfor`, `getStaEvent`, `getStaEventTable`) do not change state and are not restricted by this check. The bundled pages are administrator pages and are unaffected. Count `requireAdmin-error` in srLog to see whether anyone was relying on the old behavior.
104
+ - **Public entries**: there are none apart from the static front-end files. `/api/getUserByToken` and `/api/getPerm` verify a client user, `/api/getPermUserInfor` and `/syncAndReplaceTabs` verify an application user (`verifyAppUser`); the data channel only offers `select` on the tables (all writes go through the four functions above).
105
+
106
+ ## Upgrade Notes
107
+
108
+ ### Unreleased: external import is a `srcKey` difference sync, identity declarations are mandatory (2026-10-23, perm-run03, ADR-038; login mapping `mappingBy: 'id'` decided 2026-10-24, perm-run04, tightened in perm-run05) — two breaking changes and one behavior change
109
+
110
+ **1. `POST /syncAndReplaceTabs` is now a difference sync keyed by `srcKey` (breaking; no compatibility with the old call)**
111
+ - **What the caller must do**: every row of `rows` carries `srcKey`, a string chosen by the source, unique **within the `from`** and stable (for example an AD objectGUID); a row must **not** carry `id` (the internal primary key is generated by this system). The helper `provideTabs(url, keyTable, from, rows)` (`server/provideTabs.mjs`) now picks `srcKey` instead of `id`; if you build the request yourself, send `srcKey` and drop `id`. A batch is refused as a whole, nothing written, with `idNotAllowed` (any row carries an `id`, even an empty one; checked first), `srcKeyInvalid` (missing, blank, not a string, longer than 1978 bytes in UTF-8, or containing markup that would be rewritten), `srcKeyDuplicate` (same `srcKey` twice in the batch) `invalidRowsInPayload` (`rows` missing, not an array or empty: an empty batch is refused, it never means "delete everything of this `from`"; this key was kept from before) or `invalidRows` (a row that is not an object, a name or login-mapping key that collides with another source, ...). Direct calls with the old shape (`id` and no `srcKey`) get `idNotAllowed`.
112
+ - **What the server does now**: inside the table lock it reads the table once and matches the rows of that `from` by `srcKey`: a new `srcKey` is **added** with a new `id`; a known `srcKey` is **updated** in place (the `id` stays; a changed `name` is a rename and the references to it follow, see `opt.cascadeRename`); a stored row of that `from` whose `srcKey` is not in the batch is **deleted** and the references to it are cleaned; an equal row is left alone (**no write at all**). It returns `{ nAdd, nUpdate, nDel, nSame }`. Sending the same batch twice changes nothing the second time (`nSame` = all, same ids, same table digest). It no longer deletes the whole `from` and inserts again, so ids no longer change on every push and administrators' open pages are no longer invalidated by a push that changes nothing. Rows of other `from` values and admin rows (`from` empty) are never touched, and the `srcKey` namespace is per `from`.
113
+ - **Fields**: provided fields are owned by the source (a missing key means empty); the assignment fields `users.cgrups`, `grups.cpemis` and `pemis.crules` are optional (sent: written; missing: the stored value is kept, as before — except that with `mappingBy` `email` or `name` the value of `users.cgrups` is kept only while the login-mapping key of that `srcKey` is unchanged; if the key was changed, or the new key value is held by another stored row, nothing is carried over (the field becomes empty) and `syncAndReplaceTabs-keepAssign-skip` is logged with reason `identityConflict`, so that a login value can never inherit another person's authorization); `userId`, `timeCreate`, `userIdUpdate` and `timeUpdate` are never taken from the batch. `id`, `srcKey` and `from` are identity fields: the admin pages and `updateXxx` cannot set or change `srcKey` (a new admin row must have an empty `srcKey`; the "copy" button resets it; `saveFromNotAllowed` / `saveExternalRowChanged`, whatever `lockExternalRows` says).
114
+ - **Targets**: the `id` of a target is its name (permissions refer to it by that name), so it cannot be a generated value: for `targets` the `id` is the `srcKey` (send the target name as `srcKey`, never `id`). A `srcKey` that is already the `id` of a row of another source or of an admin row is refused (`invalidRows`, nothing written). Renaming a target is still "delete the old one, add the new one", as before.
115
+ - **One-time migration (first import after the upgrade)**: stored external rows written by the old synchronization have no `srcKey`. A row of the batch whose **`srcKey` equals the stored `id`** is taken to be that row: the server writes the `srcKey` into it in place (counted in `nUpdate`, the `id` and everything that refers to it stay), and from then on only `srcKey` counts. So the first import after the upgrade should send the **old `id` values as `srcKey`**; rows that find no partner are deleted and added as new rows (their references are cleaned, as for any other deletion). If the stored rows of one source contain a duplicate `srcKey`, or a `srcKey` claimed twice (one stored row has it, another row has it as its `id`), the batch is refused (`srcKeyConflict`); clean the source with `g_purgeFrom.mjs` and import again.
116
+ - **If a write fails halfway** the server restores the table and the referencing table to their previous content and answers `saveWriteFail` (if restoring fails as well it answers `saveWriteFail` and logs `syncAndReplaceTabs-restore-error`).
117
+ - **Warning about renumbered keys**: the page banner / `getWebInfor().syncWarns` and the log `syncAndReplaceTabs-srcKeyRenumber` now mean "same name, different `srcKey`" (a source that renumbers its keys deletes and adds every row, which cleans the references every time); a rename under the same `srcKey` is not a warning. The old `syncAndReplaceTabs-idRenumber` event no longer exists.
118
+ - **Login mapping `mappingBy: 'id'`**: decided in perm-run04, see section 4 below (an externally imported user logs in with the `srcKey` it was imported with; the generated `id` is not a login identity).
119
+ - **New message keys**: `idNotAllowed`, `srcKeyInvalid`, `srcKeyDuplicate`, `srcKeyConflict` (in `server/procLang.mjs`, eng and cht). `buildSyncRowsNew` (`server/cascadeWrite.mjs`) and `countIdRenumber` (`server/syncKeep.mjs`) no longer exist; the implementation lives in `server/syncCore.mjs`.
120
+
121
+ **2. The identity of every referenced entity must be declared by the caller (breaking; the "compatibility" of the previous section is withdrawn)**
122
+ - **What changed**: `updateUsers`, `updateGrups` and `updatePemis` used to accept a save without the `refs` / `subject` declaration described below (references by name, no identity check). A save that **changes references** — adds or removes an active entry (`isActive: 'y'`) in the assignment field of any row (`cgrups`, `cpemis`, `crules`) compared with the stored row, or adds a row that carries references — now needs a declaration for every name involved, otherwise the whole save is refused, nothing is written, with the new message key `saveIdentityRequired` ("the data was updated or this page is out of date ... reload the page"). Saves that do not change references (renaming, other fields, a change of `mode`, deleting rows) need no declaration. Removing a reference is declared like adding one (the `ids` of the entity the administrator removed it from; `[]` for a dangling reference).
123
+ - **Custom clients must send it**: `refs: [{ name, ids }]` in the same options object as `baseline` / `returnRows` (see the previous section for the exact contract; the names to list are now the active entries added **and removed**), or `subject` for a dialog-style save that only changes the entries for one group / permission (the declared `subject.name` is the only name that may change then; another changed name is `saveRefChanged`). An incomplete declaration is still `saveRefChanged`, a declaration that does not match the stored entities is `saveRefChanged` / `saveSubjectChanged`, a malformed one `saveIdentityFormat`. **Pages opened before the upgrade must be reloaded** (they do not send the declaration for removals and, in older versions, not at all).
124
+ - **No extra lock without a declaration**: a save without a declaration is decided without reading or locking the referenced table. With a declaration the lock order is unchanged (referenced table first, then the table being saved).
125
+ - Count the srLog event `updateTabItems-identity-error` by `reason`; `identityRequired` is new (with `keyTable`, `userId`, `n` = number of names, never a name or an id).
126
+
127
+ **3. Caller identity** — see "Security: caller identity" above (the body token is verified like the header token; the four write functions need `isAdmin: 'y'` in the `users` table: `userNotAdmin`).
128
+
129
+ **4. Login mapping `mappingBy: 'id'` (the default) follows `srcKey` for externally synchronized users, login values must be unique across all rows and sources, and `getPermUserInfor` follows the same mapping (behavior change, perm-run04; tightened in perm-run05)**
130
+ - **The rule**: the id that your `getUserByToken` returns is matched, among the rows of the `users` table, against the `id` of a row created in the admin pages (`from` empty, as before) and against the **`srcKey`** of a row synchronized from an external source (`from` not empty); a login needs exactly one matching row and that row must be active (perm-run05: matches are counted whether or not the rows are active, see "A clash is refused at every entrance" below). The `id` of an external row is the internal primary key that this system generates (section 1); it is **no longer a login identity**, so a user imported after the upgrade logs in with the id of the source system (the `srcKey` you sent), and everything inside the system (the pages, `userId` / `userIdUpdate`, `getUserIdByToken`) uses the internal `id` of the row. `mappingBy: 'email'` and `'name'` are unchanged (they never looked at `srcKey`). Two things follow for your deployment: (a) the id returned by `getUserByToken` must identify one person across **all** your sources, and (perm-run05) it must be unique across all sources **whether or not the rows are active** (the login does not know which source a token came from, and different external systems often hand out the same user id to different people, so a disabled user of one source must never let a token reach the active user of another source with the same value; the import and the admin pages now refuse to store such a pair, and a login that matches more than one row is refused with `ambiguousUserFromPerm`); (b) **`/api/getPermUserInfor?userId=` and `getPermUserInfor(url, token, userId)` resolve `userId` by the same login mapping (behavior change, perm-run05)**: with `mappingBy: 'id'` pass the id of the source system, that is the **`srcKey`** of an external row or the `id` of an admin row, not the internal `id` that this system generated; a value that matches more than one row is refused (HTTP answer `ambiguousUserFromPerm`, the SDK rejects with `cannotGetUserDataByUrl`), and a value that matches no active row answers the all-`n` rules of an unknown user, as before. A caller that passes the internal `id` of an external row now gets those empty rules (the user is not found), unless that value happens to equal the login value of another row, in which case it gets that other row's rules (the same rule as a login), so update such callers to pass the `srcKey`. With `mappingBy: 'email'` or `'name'` `userId` is still the internal `id` of the row, as before. `/api/getPerm` (the signed-in user's own permissions) is not affected.
131
+ - **Existing external users keep working**: a row migrated by the one-time migration (section 1: `srcKey` equals the old `id`) logs in as before. Until the first import after the upgrade, a stored external row that has no `srcKey` yet is still matched by its `id` (the same as before the upgrade), so nobody is locked out between the upgrade and the first push.
132
+ - **A clash is refused at every entrance, whether or not the rows are active, and across sources (perm-run05; perm-run04 refused only an active external row against an active admin row)**: the login value of a row is its `id` (admin row) or its `srcKey` (external row; a row stored before the upgrade that has no `srcKey` yet keeps using its `id`). Two rows with the same login value clash unless both are admin rows (a duplicate primary key is refused elsewhere) or both belong to the same source (`srcKeyDuplicate` / `srcKeyConflict` handle that). So the `srcKey` of an external row must differ from the `id` of every admin row and from the `srcKey` of every row of every other source, **disabled rows included** (a disabled row can be enabled again, and a login that matches two rows is refused whichever of them is active). An import batch with such a row is refused as a whole with `loginIdCollision` and nothing is written (the answer is the fixed message key, it never repeats the value; ask the owner of the source to send a key that is unique across all sources, or run with `mappingBy: 'email'` and a unique email); a save in the admin pages / `updateUsers` that adds an admin row whose `id` equals the `srcKey` of any external row, or changes the login identity of a row so that it equals another row's, is refused with `loginIdCollision` and nothing is changed (a clash that is already stored does not block other saves, so it can be repaired by deleting or changing one side; turning a disabled row on or off is not a change of its login value); and if the database nevertheless holds a clash (written around the product, or stored before this release), the login is refused with `ambiguousUserFromPerm`, never the first match, and also when only one of the clashing rows is active. A login that matches exactly one row which is disabled is `cannotGetUserFromPerm`, as before.
133
+ - **Audit and repair of clashes that are already stored (do this before or right after the upgrade)**: the upgrade does not change data. Export `{ users, grups, pemis, targets }` as JSON and run `node server/tools/nameDupReport.mjs <export.json> --mapping-by id` (read-only): the section "users 對應鍵 id" lists every group of rows that share one login value, whether across sources (marked as a cross-source clash, `scope: crossSource` in the JSON) or duplicated inside one source (`scope: sameSource`; for example a disabled and an enabled row of the same source with the same `srcKey`), with the id, name, `from` and `isActive` of each row. Nobody in such a group can sign in (`ambiguousUserFromPerm`; count the srLog event `getTokenUser-error` with that `msg`, it carries `mappingBy` and the number of matches, never the value), and the next push of a source that takes part in a cross-source clash is refused with `loginIdCollision` until the clash is gone. A `sameSource` group (two rows of one source with the same `srcKey`, or one row holding the value as its `srcKey` while another row of the same source holds it as its old `id`) is not a cross-source clash: the push of that source is refused with `srcKeyConflict` whatever the batch contains, and sending a different `srcKey` does not clear it, so clean that source first with `g_purgeFrom.mjs` (see section 1) and then import again. To repair: delete the admin row in the admin pages (an admin row can always be deleted or changed there; saves that do not touch a clashing row are not blocked), and for clashes between sources ask the owner of one source to change its `srcKey` for that user (the next push replaces the row; a rename of the key deletes the old row and adds a new one, as described in section 1) or remove that source's rows with `g_purgeFrom.mjs`; run the report again until it lists 0 groups. **Change the value that your `getUserByToken` returns at the same time**: if two sources hand out the same id to two different people, changing only one source's `srcKey` is not enough, because the token of the person whose key you changed still reports the old value and would then log in as the other person who still holds it (or be refused). Make the source's users distinguishable end to end, for example by adding a source prefix (`A|7`) both in the `srcKey` that the source sends and in the id that `getUserByToken` reports for its tokens, or map users by `email` instead.
134
+ - **What to do**: when you run with `mappingBy: 'id'`, send the id that `getUserByToken` returns for each user as its `srcKey`, and make sure it cannot repeat in another source. If an admin-created user's `id` equals a `srcKey` that your source sends, or another source already holds the same `srcKey`, change one of them before the first push (the push answers `loginIdCollision`; the srLog event `syncAndReplaceTabs-error` / `updateTabItems-ckKey-error` carries the reason — `srcKeyIsAdminId`, `srcKeyOtherFrom` or `adminIdIsSrcKey` — and the row index but never the values).
135
+ - **New message key**: `loginIdCollision` (eng and cht in `server/procLang.mjs`; the text was rewritten in perm-run05 to cover disabled rows and other sources and to suggest a key that identifies the user uniquely). The banner text `syncWarnIdRenumber` (external source appears to have renumbered its keys) now speaks of source keys (`srcKey`), the same rule as the difference sync.
136
+
137
+ ### Unreleased: a saved reference must still point to the entity that was selected, checked by the page and by the server (2026-10-23, perm-run02, ADR-037 Update)
138
+ - **Behavior change (the whole save is refused, nothing is written)**: authorization refers to other entities by **name** (users' `cgrups` to a group name, groups' `cpemis` to a permission name, permissions' `crules` to a target id). If the group / permission / target that the administrator has just assigned, or the group / permission a "belongs to" dialog was opened for, is **deleted, or deleted and re-created under the same name (a different `id`)**, before the save reaches the database, the save used to write the authorization onto the new entity without a word (it is a different entity that merely shares the name). Now the save is refused as a whole: the server answers `saveSubjectChanged` (dialogs) or `saveRefChanged` (list pages), and the page's own earlier check shows `relSubjectChangedGrup` / `relSubjectChangedPemi` (dialogs) or the same `saveRefChanged` (list pages); reload the page (close the dialog first) and redo the edit. Also refused: on a list page, another entity with the assigned name has been **added** since the selection; in a dialog, a **rename** of its group / permission (same `id`, another name, as before), or, when the group / permission was still unsaved in the list when the dialog was opened, another entity that has taken its name since. The check is made twice: by the page against its data store before it sends, and, as the final decision, **by the server inside the lock of the referenced table** (`grups` for users, `pemis` for groups, `targets` for permissions), so a delete-and-re-create that the page has not received by push yet no longer slips through. Before this release the dialogs' check was made by the page only, and the assignment columns of the list pages were not checked at all.
139
+ - **What is checked**: only references that **this save newly activates** (an entry with `isActive: 'y'` that was not active in the stored row), compared with the entity the administrator selected **at the moment of selecting it in the editor** (not at the time the page was loaded: an entity that someone else created after the page was loaded, and that the administrator then assigned, is not refused). Unchanged old references, disabled entries, removed entries and a change of `mode` are not checked (they are existing relations; deleting an entity already removed the references to it). A name that matched nothing when it was selected (a dangling reference) is still allowed, unless an entity of that name has appeared since. If several entities carried the name when it was selected (historical duplicates), the save is refused when that set of entities is not exactly the same any more.
140
+ - **Targets are identified by their `id`**: a target's primary key is its name and the external synchronization rewrites target rows under the same `id` every time, so for `crules` only "the target has been deleted" is detected; a target that is deleted and re-created under the same `id` is the same entity by definition (this is a design fact, not a defect; telling them apart by creation time would need a separate decision, and the creation time is not stable under the synchronization).
141
+ - **RPC contract (optional, only tightens)**: `updateUsers`, `updateGrups` and `updatePemis` take two more optional keys in the same options object as `baseline` and `returnRows` (optional third argument of the server function, second argument of the client call):
142
+ - `subject: { id, name, saved }` for the "belongs to" dialogs: `updateUsers` (the group the dialog was opened for) and `updateGrups` (the permission). `id` and `name` are non-empty strings and `saved` is a boolean (the group / permission already existed in the page's data when the dialog was opened). Inside the lock of the referenced table the server reads it afresh and requires the same `id` with the same name (when `saved` is `false`: no other entity with that name); otherwise `saveSubjectChanged` (reason `gone`, `renamed` or `taken` in the log). It is checked before the baseline comparison, so "the group was replaced" is reported instead of the generic `saveStale`.
143
+ - `refs: [{ name, ids }]` for the assignment columns of the list pages (`updateUsers` → `grups`, `updateGrups` → `pemis`, `updatePemis` → `targets`): one element for every reference the save newly activates, `name` being the referenced name (for `crules` the target id) and `ids` the array of entity ids (strings) that carried that name when the administrator selected it (`[]` when none did). The server requires that the ids of the entities carrying each declared name are, right now, exactly the declared ones (a deleted entity, a re-created one with another `id`, an additional entity with the same name all differ), and that no newly activated reference is missing from `refs` (`saveRefChanged`, reason `refChanged` or `refIncomplete`). Declaring a name that this save does not activate is harmless when it still matches.
144
+ - **A malformed value is refused, never ignored**: a `subject` that is not an object, lacks `id` or `name` (or has an empty one), or has a `saved` that is not a boolean; a `refs` that is not an array, has more than 5000 elements, more than 1000 ids for one name, a duplicate name, a non-string name or a non-string id; `subject` sent to `updatePemis`; or either key sent to `updateTargets` (targets have no authorization column): all are answered with `saveIdentityFormat` before anything is read or written. The referenced table and its lock are chosen by the server from the table being saved; the client cannot name them, and the declared values are never trusted without the comparison above.
145
+ - **(Withdrawn in perm-run03, see the section above: a save that changes references without a declaration is now refused with `saveIdentityRequired`.) External callers and old pages are unaffected (compatibility)**: a call without `subject` / `refs` (or with `null`) behaves exactly as before: references by name, no identity check, the lock of the referenced table is not even taken; it never allows more than before. A page opened before the upgrade does not send them and keeps the old behavior until it is reloaded. The bundled pages send them (the three list pages send `refs`, the two dialogs send `subject`; the targets page sends neither). Making them mandatory for every write, as `requireBaseline` would for the baseline, is left for a later version.
146
+ - **It only takes back, never gives more**: the only saves that are newly refused are those that would have written a reference onto an entity other than the one selected; no save that was refused before is accepted now. To see how often it happens, count the srLog events `updateTabItems-identity-error` by `reason`: `gone`, `renamed`, `taken` (dialogs), `refChanged`, `refIncomplete` (list pages) and `format` (malformed value); the event carries `keyTable`, `userId`, `msg` (the message key) and `n` (how many names), and never a name, an id or a declared value.
147
+ - **Locks**: with `subject` / `refs` the save takes the lock of the referenced table first (outermost) and the lock of the table being saved inside it; this follows the same order as before (`targets` → `pemis` → `grups` → `users`: referenced table first), so it cannot deadlock with the external synchronization or the rename / delete cascade (a random concurrent test over the four tables, 60 rounds, is part of `test/unit-procCore-identity.test.mjs`). A save without `subject` / `refs` does not take it.
148
+ - **New message keys**: `saveSubjectChanged`, `saveRefChanged`, `saveIdentityFormat` (in `server/procLang.mjs`, eng and cht, fixed texts that contain no name). `relSubjectChangedGrup` / `relSubjectChangedPemi` stay as the page's own message (they name the group / permission).
149
+ - **Known limitations of this check**: (1) a row that the administrator copies or pastes carries no "selected in the editor" record, so its declaration is taken from the page's data at the moment of sending; that only covers the time between sending and writing, not the time between copying and sending. (2) A caller that sends no `subject` / `refs` still refers by name and gets no protection (see compatibility above). (3) Targets are identified by `id` (see above). (4) The page's own check uses its data store, which can be one push interval old; the server's check is the final decision.
150
+
151
+ ### Unreleased: shared multi-user editing core (`w-serv-orm` 1.1.17), self-conflicts fixed (2026-10-23, perm-run01, ADR-037 Update)
152
+ - **Reload the admin pages once after the upgrade (behavior change)**: a page that was opened *before* the upgrade carries a baseline in the old fingerprint format. Its next save is refused with the new message `saveBaselineFormat` ("this page was opened with a different version of the system ... reload the page"), nothing is written, and the draft stays on screen. A custom client that builds its baseline with the old `makeBaseline(rows, keys)` must call `makeBaseline(rows, '<table name>')` (or use `buildBaseline` of `w-serv-orm/src/baseline.mjs` with the contract in `src/schema/contracts/`); a baseline in any unknown format is refused and never treated as "no baseline". Clients that send no baseline are unaffected.
153
+ - **Dependency**: `w-serv-orm` is now `^1.1.17` (the shared baseline functions, the per-instance table lock `withTabLocks`, `guardWrite`, and the `mustExist` option of `procOrm`). The backend no longer builds its own mutex.
154
+ - **What is compared now**: the digest of a row follows the table's write contract (the four contracts in `src/schema/contracts/`: client-written fields, server-managed fields, rank field `order`). Compared with the previous fingerprint: (1) `undefined`, `null` and `''` are the same empty value (a field cleared by one administrator no longer collides with a field that was already empty); (2) **the rank of the rows is part of the baseline**: another administrator who only reorders the rows (drag and drop) makes a save from an older view fail with `saveStale`, which was not a conflict before (the later save's order won); renumbering `order` without changing the rank, which is what every save and a synchronization of external rows do, is not a conflict; (3) numbers and strings are never converted into each other.
155
+ - **Your own save no longer blocks your next save (fixed gap)**: `updateXxx(rows, { baseline, returnRows: true })` returns the rows as they are stored after the write (read back inside the table lock), and the four list pages and the two "belongs to" dialogs put them into their data store at once; the baseline of the next edit is built from them. Before, saving again within about two seconds (before the next push) was refused as a conflict with yourself. The push that arrives later carries the same content and is not treated as someone else's update. Without `returnRows` the call still returns the plain message key `'saveTabItemsSuccess'`.
156
+ - **The "belongs to" dialogs follow the data (fixed gap)**: a dialog that is open and has no unsaved edit is rebuilt from a push that changes the table (and its baseline is renewed), so a later save is not refused for data the dialog never showed. With unsaved edits the dialog keeps its draft and its baseline and shows the notice, as before.
157
+ - **A cell editor that is still open counts as a draft (fixed gap)**: text typed into a cell but not yet committed with Enter used to be lost when a push arrived; now the page does not rebuild, keeps the baseline, shows the "data was updated" banner, and rebuilds once the editor is closed without a change (Esc or no edit). The "belongs to" dialogs have no cell editors.
158
+ - **The dialogs check that their group / permission is still the same one (new check, behavior change)**: the dialogs refer to the group (users' `cgrups`) or the permission (groups' `cpemis`) by **name**. If that group / permission is deleted, renamed, or deleted and re-created under the same name (a different `id`) while the dialog is open, the dialog shows the "data was updated" notice and its save is refused (`relSubjectChangedGrup` / `relSubjectChangedPemi`, nothing is written); close the dialog, reload the page and try again. A group / permission that is still unsaved in the list when its dialog is opened keeps the old behavior unless an entity with the same name appears meanwhile. (Since perm-run02 the server repeats this check; see the section above.)
159
+ - **Only-modify writes and server-managed fields**: updates and deletes carry `{ mustExist: true }` (a row that vanished is reported as `saveStale` and never re-created); `userId`, `timeCreate`, `userIdUpdate` and `timeUpdate` of an existing row are no longer taken from the client for admin rows (they already were not for external rows). The external synchronization keeps its semantics (`delAll({ from })` plus insert); only its lock is now the shared one.
160
+ - **New message keys**: `saveBaselineFormat`, `relSubjectChangedGrup`, `relSubjectChangedPemi` (in `server/procLang.mjs`, eng and cht).
161
+
162
+ ### Unreleased: external rows are protected from the admin pages, and saves carry a load baseline (2026-10-22, F-23 / F-26, ADR-037)
163
+ - **External rows are protected from the admin pages (behavior change, `opt.lockExternalRows`, default `'y'`)**: a row whose `from` is a non-empty string is an *external row* (normally written by an application through `/syncAndReplaceTabs`, which only accepts a non-empty `from`); a row whose `from` is `''`, missing, or not a string (`null`, number, boolean) is an *admin row*. With `lockExternalRows: 'y'` the server enforces the following for the admin pages and for direct `updateTargets` / `updatePemis` / `updateGrups` / `updateUsers` calls alike. The whole save is refused, nothing is written and nothing is silently ignored: (1) a **new** row must have `from === ''` (a copy of an external row, a re-created row and a typed value are all refused), and an admin row cannot be turned into a row with a non-empty `from`: `saveFromNotAllowed`; (2) an external row cannot be **deleted**, cannot change its `from`, and cannot change any identity / structure field (every schema field except `id`, `from`, `order`, the assignment field and the server-managed fields `userId` / `timeCreate` / `userIdUpdate` / `timeUpdate`; for `users` this includes `name`, `email`, `isAdmin` and `isActive`): `saveExternalRowChanged`. The **assignment fields** (`users.cgrups`, `grups.cpemis`, `pemis.crules`; targets have none) stay writable from the admin pages ("Lv1"), but changing one on an external row needs the load baseline described below. The `order` of an external row belongs to the external source (a save does not renumber it), and an external row that differs from the stored one only in server-managed fields counts as unchanged. `opt.lockExternalRows: 'n'` restores the old behavior (only the `from` type check of the next item remains). The setting is returned by `getWebInfor()` as `lockExternalRows` (`'y'` / `'n'`).
164
+ - **The admin pages show external rows as read-only**: a lock icon with a tooltip in the first column, a banner above the list (rendered only when the table has external rows or there is a synchronization warning; otherwise the page is exactly as before), the `from` column read-only on every row (also with `lockExternalRows: 'n'`; before this release it could be edited in the list pages), the `isAdmin` / `isActive` check boxes of an external user disabled, "delete" skipping external rows with a notice, and "copy" resetting `from` to `''`. This is a convenience: the server is the enforcer (a paste into a read-only cell, see the known limitations below, is stopped by the page before sending and by the server anyway).
165
+ - **An administrator cannot disable or delete an external user (policy)**: identity fields, including `isActive` and `isAdmin`, belong to the external source. To disable an external user urgently, do it at the source (push the source's rows without that user, or with `isActive: 'n'`, and wait for the next synchronization) or remove the source's rows offline with `g_purgeFrom.mjs` (see the upgrade steps below). There is no local "disable override" on purpose (the next synchronization would overwrite it), and no way to "claim" an external row as an admin row (the source would then be refused for that id, `idOtherFrom`).
166
+ - **`from` must be a string (behavior change)**: whatever `lockExternalRows` says, a save that adds a row with a non-string `from` (`null`, number, boolean, ...) or changes the `from` of a stored row to a non-string is refused with `saveFromNotAllowed`. A historical row whose `from` is `null` and which is sent back unchanged is not refused, so a database that still holds such rows can be saved.
167
+ - **`updateXxx(rows, { baseline, returnRows })` (RPC contract, optional third argument of the server function, second argument of the client call)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` accept `{ baseline }`, where `baseline` is `[[id, digest], ...]` describing the rows the edit is **based on**. Since 2026-10-23 (perm-run01) the baseline is the shared **`h1`** baseline of `w-serv-orm` (`buildBaseline` of `w-serv-orm/src/baseline.mjs`; each digest is `'h1:'` plus 14 lowercase hex digits; the **order of the array is the rank** of the row, by `order` then primary key) and the digest is computed from the table's **write contract** (`src/schema/contracts/{users,grups,pemis,targets}.mjs`: `fields` is the list of client-written fields, `serverOnly` the four server-managed fields, `orderKey` is `order`). Build it with `makeBaseline(rows, 'users')` of `src/js/rowDigest.mjs` (the second argument is now the table name, not a key list). The digest ignores `order` itself and the server-managed fields (a synchronization rewrites the time fields of every row of its source, and a save renumbers `order`, so neither may invalidate a draft); `undefined`, `null` and `''` are the same empty value. Inside the table lock the server compares the **whole table** with the baseline: if any stored row has a different digest from the one the baseline lists for its `id`, a row was added or removed, **or the rank of the rows is different (someone only reordered them)**, the whole save is refused with `saveStale` (reload the page and redo the edit) and nothing is written, whether or not that row is one the save would write. Renumbering `order` without changing the rank (a save of the table, a synchronization that shifts the `order` of its own rows) is not a change. A baseline in any other format (not an array, a duplicate `id`, a version other than `h1`, **or the fingerprint format of releases before this one**) is refused with `saveBaselineFormat` (reload the page) before anything is read or written; it is never treated as "no baseline". Every other key of the client's second argument is ignored (only `baseline`, `returnRows` and, since 2026-10-23, `subject` and `refs` are taken — see "Upgrade Notes: a saved reference must still point to the entity that was selected" — and nothing of it is passed on to the ORM). **With `returnRows: true` a successful call returns `{ msg: 'saveTabItemsSuccess', rows, others }` instead of the plain message key**: `rows` are the rows of the table as read back inside the same table lock after the write (sorted by `order`), `others` holds the rows of the referencing table when the save rewrote it (rename / delete cascade, keyed by table name, otherwise `{}`). The bundled pages use it to refresh their data and baseline at once instead of waiting for the next push. **A custom client that sends no baseline keeps the old behavior for admin rows (last writer wins, ADR-013), but cannot change the assignment fields of an external row: it gets `saveBaselineRequired`.**
168
+ - **[Updated in perm-run03: the external synchronization is no longer `delAll({ from })` plus insert; it is a `srcKey` difference sync, see the first section of these notes. The lock sharing described here is unchanged.]** **One lock manager, only-modify writes, server-managed fields are not taken from the client (2026-10-23, perm-run01)**: the backend takes the lock of every table from the `w-serv-orm` instance (`withTabLocks` / `guardWrite`, `^1.1.17`); the admin saves, the external synchronization and the cascade write to the referencing table share the same lock per table (acquisition order: referenced table first, referencing table inside: `targets` → `pemis` → `grups` → `users`). Updates and deletes written by a save carry the server-composed option `{ mustExist: true }`: a row that is no longer there is never re-created (`rowNotFound`, reported as `saveStale`). `userId`, `timeCreate`, `userIdUpdate` and `timeUpdate` of an existing row are never taken from the client any more (for admin rows as well as for external rows); a row that differs only in those fields is not written.
169
+ - **The bundled pages bind the baseline when the edit draft is created, never at save time**: the four list pages compute it where they rebuild their rows from the data store (so a push from the server replaces draft and baseline together when there are no unsaved edits; with unsaved edits the draft and its baseline are kept, see the next item), and the two "belongs to" dialogs compute it when they open and keep it until they are opened again. A baseline taken at save time would only prove that the store is new, not that the edited content is. The "belongs to" dialogs (group page "users", permission page "groups") also changed how they save: they send the whole table as the store snapshot taken **at save time** and change only the one entry of the rows the administrator actually changed (`src/js/relDialogSend.mjs`); every other row goes back unchanged, so a user or group that someone else, or an external synchronization, added while the dialog was open is no longer deleted by the save.
170
+ - **[Updated in perm-run03: external sources no longer push `id`s (an `id` is refused); the stability that matters is the `srcKey`, and an unchanged push no longer writes at all. Note (1) below is superseded.]** **Concurrent editing: first writer wins, and the page no longer drops a draft silently (behavior change, 2026-10-22, run11; ADR-037 Update)**: when the stored table differs in **any** row (content, ignoring `order` and the server-managed fields), or in the set of rows (a row added or removed), from the table the administrator's draft was loaded from, the whole save is refused with `saveStale` and nothing is written: no partial save, no lock; the first writer wins and the other must reload. Before this change only the rows the save would write were compared, so another administrator's edit of a *different* row did not make a save fail; now it does. The comparison is by content: an external synchronization that rewrites its rows without changing their content (the creator / time fields and `order` change) does not change the table digest, so it does not block an administrator; one that really changes a row does (the external source is the master). A save from a client that sends no baseline is not compared (admin rows: the later write wins, ADR-013; changing the assignment fields of an external row: `saveBaselineRequired`). **The bundled list pages keep an unsaved draft**: when a push brings new data while the page has unsaved edits, the draft and the baseline it was loaded with are kept (nothing is rebuilt), and, if the table now differs from that baseline, a banner explains that the data was updated by someone else or by a synchronization, that saving will be rejected, and offers a "Reload" button that discards the draft and reloads the table (`staleDraftNotice` / `staleDraftReload`); a page without unsaved edits is rebuilt from the push as before. The two "belongs to" dialogs show `staleDraftNoticeDialog` when a push arrives while they hold unsaved edits and the table differs from the one they were opened with (close and reopen to reload). Notes: (1) an external source that pushes rows **without `id`** gets new random primary keys on every push, which changes the set of rows and therefore the digest every time; keep `id` stable (as already advised) or administrators' open pages will be refused after each synchronization; (2) *changed in 2026-10-23, perm-run01*: the rank of the rows is part of the baseline now, so another administrator who only changes the order of rows (drag and drop) **does** make a save from an older view fail, while a pure renumbering of `order` that keeps the rank (a save of the table, an external source shifting its own rows) does not; (3) `saveStale` text and the position of the reload button are covered by `test/e2e-external-rows.test.mjs` E2E-016 to E2E-024.
171
+ - **[Updated in perm-run03: rows are matched by `srcKey` (not by `id`, `email` or name), the matching rules for rows "without `id`" below no longer apply; keeping a missing assignment key, the `cascadeRename` behavior and the not-keepable skip still hold.]** **External synchronization keeps the assignments made in the admin pages**: a `users` row pushed to `/syncAndReplaceTabs` **without a `cgrups` key** keeps the `cgrups` stored for the matching row of the same `from` (matched by `id`; a row without `id` is matched by `email` / `name` when `mappingBy` is `'email'` / `'name'` and exactly one stored row of that source has the value; no match gives `''`); a pushed `cgrups` key, even `''`, is taken as given. When `mappingBy` is `'email'` / `'name'`, a row matched by `id` keeps its assignment only if its `email` / `name` is unchanged (an `id` that now points to a different login identity is treated as a different user: the assignment is cleared rather than moved), and a stored value that could not pass the normal pre-write validation is not kept either (logged as `syncAndReplaceTabs-keepAssign-skip`). `grups.cpemis` and `pemis.crules` are treated the same way (all three are assignment fields that the admin pages may write): a `grups` row without a `cpemis` key, or a `pemis` row without a `crules` key, keeps the stored value of the same entity of the same `from`; a pushed key, even `''`, is taken as given. Groups and permissions have no login key, so the entity is identified by `id`; a row without `id` (the server gives it a new random primary key) is matched by name and exactly one stored row of that source must have that name. A rename under the same `id` keeps the value while `opt.cascadeRename` is on (the references are rewritten to the new name, so it is the same entity); with `cascadeRename: false` the references do not follow, the name is the identity, and the value is not moved to the new name (logged as `syncAndReplaceTabs-keepAssign-skip` with `reason: 'identityConflict'`, as is a row without `id` that matches several stored rows with the same name). An `id` that is not stored for that source is a new entity (`''`). Stored values that could not pass the normal pre-write validation are not kept (`reason: 'notKeepable'`). **Behavior change for `cpemis` / `crules`**: before this release a push without these keys cleared what the admin pages had assigned, on every push; now it is kept. If your application relied on the push to reset the assignments, send the key explicitly (for example `cpemis: ''`). Renumbered ids are covered by the next item.
172
+ - **[Superseded in perm-run03: the warning now means the same name under a different `srcKey`; the log event is `syncAndReplaceTabs-srcKeyRenumber`. The advice "keep ids stable or omit ids" is replaced by "keep `srcKey` stable". The wording of the page banner (`syncWarnIdRenumber`) still says "ids" because changing it would change two standard images.]** **Warning when an external source seems to renumber its ids**: when a push for `grups` or `pemis` contains, for one `from`, a row whose name belonged to a stored row of that source with an id that is not in the push (the same name under a different id), the server logs `syncAndReplaceTabs-idRenumber` (table, source and count only) and lists `{ keyTable, from, n, time }` in `getWebInfor().syncWarns`; the admin page shows a banner, and the entry is removed by the next push of that source without the pattern. It is kept in memory only (a restart clears it) and does not change what the push does: the advice "keep ids stable (or push without ids)" above still applies, because a vanished id is a deletion and removes the references to it.
173
+ - **Two new confirmations in the admin pages**: saving a new group, permission or target whose name is still referenced by active entries (users' `cgrups`, groups' `cpemis`, permissions' `crules`; the reference was dangling until now) asks first, and says how many users (groups, permissions) will get, or be limited by, the new entity and how many of them refer to it with `AND` (a target's rule entries have no mode, so no `AND` sentence); adding it is allowed, saving only continues after "Yes". When a duplicate name involves an external row, the warning icon of the name cell says that the external source already uses the name (the server still refuses an admin row whose name an external row has, and an external push whose name an admin row has, as before).
174
+ - **Upgrade steps (stop the backend first)**: after the upgrade every row whose `from` is a non-empty string is an external row and becomes read-only in the admin pages, including rows that were typed or inherited by "copy" in the admin pages in the past. The development seed now writes `from: ''` for the users `peter`, `mary` and `john` (it was `teamA`; `g_initialData.mjs`, `src/schema/tables/users.mjs`), so **they are admin rows, not external rows**; a database created with the earlier seed still carries `teamA` on them and should be rebuilt with `g_initialData.mjs`. Recommended order: (1) stop the backend and upgrade; (2) `node g_fixFromType.mjs` lists the rows whose `from` is not a string (type distribution and the first ids; nothing is changed) and `node g_fixFromType.mjs --apply` rewrites them to `''` (admin rows) after backing up the database and the four tables; (3) `node g_purgeFrom.mjs` lists, per table, how many rows carry each `from` (non-empty ones are marked as external); (4) if an external source is gone, `node g_purgeFrom.mjs --table <targets|pemis|grups|users|all> --from <source>` lists what would be removed and which references would be cleaned (dry run), and the same command with `--apply` removes that source's rows together with the references to them (the same effect as deleting them in the admin pages, which also removes the references, see above; planned with the same function as the server's reference cascade; backup first, the written result is verified; `--from ''` is refused; `--db <folder>` and `--backup <file>` are available); the apply steps refuse to run when the database is held by another process (detected on Windows only, elsewhere check yourself); (5) start the backend. There is no tool that turns an external row into an admin row: rows that were created in the admin pages with a non-empty `from` have to be purged and re-created, or keep the old behavior with `opt.lockExternalRows: 'n'`. **Running the tools from the installed package**: `g_fixFromType.mjs`, `g_purgeFrom.mjs` and `g_authRefsClean.mjs` and their cores (`server/tools/fromTools.mjs`, `server/tools/authRefsClean.mjs`, `server/tools/authRefsAudit.mjs`, `server/tools/nameDupReport.mjs`) are part of the npm package (they do not depend on `test/`, which the package does not ship). Run them with the **working directory set to the project root of your deployment**, using the path inside `node_modules`, for example `node node_modules/w-web-perm/g_purgeFrom.mjs` or `node node_modules/w-web-perm/g_fixFromType.mjs --apply`. The data folder is relative to the working directory (`g_getSettings.mjs`: `dbUrl: './db'`, database name `wperm`, so the data lives in `./db/wperm` of the folder you run it in; use `--db <folder>` for another location; the tools stop with an error when `<folder>/wperm` does not exist, but they do not check that it already holds your data, so point them at the right folder), and the backup files they write (`backup-<time>.json`, `purgeFrom-backup-<time>.json`, `authRefsClean-backup-<time>.json`) also land in the working directory. The packages they import (`w-orm-lmdb`, `w-serv-orm`, `wsemi`) are resolved from the installed package location, so they must be installed in your project. The apply steps of the three scripts stop before any backup or write when a row would not survive the JSON backup unchanged (`NaN`, `Infinity`, `bigint`, `Date`, `undefined`, `Buffer` values). The offline audit helpers that read an export file, `server/tools/authRefsAudit.mjs` and `server/tools/nameDupReport.mjs`, are run the same way (`node node_modules/w-web-perm/server/tools/nameDupReport.mjs <export.json>`). The one exception is `test/tools/authMergeReport.mjs`, which is built on the test harness (golden data, the merge-rule model) and is **not** shipped: run it from a source checkout of the same version.
175
+ - **Known limitations**: (1) **w-aggrid-vue**: the paste path of `WAggridVue.vue` (`node_modules/w-aggrid-vue`, 2.1.9 when checked; lines 1556-1558 and 1648) decides editability by the truthiness of the column's `editable`, and the per-row function used for external rows is always truthy, so Ctrl+V can write into a cell that is read-only on that row; the page checks the rows before it sends them and shows `saveExternalRowChanged`, and the server refuses such a save anyway. (2) `syncWarns` is not live: the page reads `getWebInfor()` once at start-up (`src/main.js`), so a warning raised later shows after a reload. (3) Concurrent editing is compared for the **whole table** (see "Concurrent editing" above): after your draft was created, a change by someone else or by a synchronization to **any** row, or a row added or removed, refuses the save with `saveStale`; the draft is kept and a banner offers "Reload". The five gaps that 1.1.13 listed here (a: saving again within about two seconds after your own successful save; b: a "belongs to" dialog that was opened but not edited stayed on the old data; c: a cell editor that is still open lost its typed text when a push arrived; d: `null` and `''` counted as different; e: a change of `order` alone was not a conflict) are fixed since 2026-10-23 (perm-run01), see "Upgrade Notes: shared multi-user editing core". What remains: the "belongs to" dialogs have no cell editors (their cells are check boxes and chips that commit at once), so an open-editor draft exists only in the four list pages; the earlier limitation "the check that the dialog's group / permission is still the same entity (not deleted and re-created under the same name) is done by the page against its data store, which is at most one push interval old, not by the server" is **resolved since 2026-10-23 (perm-run02)**: the server repeats that check inside the lock of the referenced table when the dialog sends `subject`, and the assignment columns of the list pages are checked the same way with `refs` (see "Upgrade Notes: a saved reference must still point to the entity that was selected"). What remains there: targets are identified by `id`, and a caller that sends neither `subject` nor `refs` is not protected. (4) A client that sends no baseline is not protected for admin rows (last writer wins). (5) With `modeEditUsers: 'for:grups'` the server keeps taking only `cgrups` from the rows, so an attempted change of an external user's identity fields is ignored there instead of refused (existing design; nothing is written). (6) With `lockExternalRows: 'n'` none of the protections above except the `from` type check apply, and the pages show external rows as editable again (the `from` column stays read-only and "copy" still resets `from`).
176
+
177
+ ### Unreleased: objects carrying a numeric `length` key, and non-array `rows` (2026-10-20)
178
+ - **`rows` must be an array (behavior change)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` now reject a `rows` argument that is not an array (a string, number, `null`, `undefined`, a plain object, or an object carrying a numeric `length` key), or whose elements are not non-empty objects (`[null]`, `[{}]`, `[""]`, `[[]]`), with `saveRowFieldInvalid` and leave the table unchanged. Previously such input was treated as an empty table: the whole table was cleared and the call reported success (in the `modeEditUsers: 'for:grups'` mode it was silently ignored). An empty array `[]` is still accepted (it clears the table). The bundled front end and `/syncAndReplaceTabs` (which already required a non-empty array) are not affected.
179
+ - **Objects are iterated by their own keys**: a field or key named `length` is an ordinary key everywhere user-supplied objects are walked (write validation, the `cgrups` / `cpemis` / `crules` JSON strings evaluated by `getPerm` / `getPermUserInfor`, and the list pages). Before, lodash treated such an object as an array-like of that length: real keys were skipped (for example a group listed next to `"length": 0` was ignored, so its rules evaluated to `n`) and a huge `length` made the call spin or run out of memory.
180
+ - **`/syncAndReplaceTabs` rows must be non-empty objects (behavior change)**: every element of `rows` must be an object with at least one key. A push containing a string, number, `null`, array, boolean or an empty object `{}` is rejected as a whole with `invalidRows` before anything is deleted. Before, a non-object element made the call fail with an engine message (for example `Cannot create property 'from' on string 'abc'`) that was returned to the application, and an empty object in a multi-row push was written as a blank row with a generated id. A row that carries no schema field at all (for example `{"foo":1}`; it would map to an all-empty row) is rejected the same way.
181
+ - **Authorization lookups use the exact id, not a lodash path (behavior change, tightening)**: `getUserRules` (used by `getPerm` / `getPermUserInfor`), the SDK `perm().active(name)` and the edit dialogs of user groups, group permissions and permission rules now look a target, group or permission up by its exact name as an own property. Before, a name containing `.` or `[` was resolved as a path: when only `A` was granted, targets `A.0` and `A[0]` evaluated to `y` (and `A.length` to `1`), and inherited names such as `toString` could be returned. Names that are themselves keys (for example a target called `A.0` granted by a rule named `A.0`, or one called `constructor` / `__proto__`) are granted as usual. In the admin session of `modeEditUsers: 'for:grups'`, a database id containing `.` or `[` can no longer be taken over by another row's nested field. The edit dialogs also keep an item named `__proto__` when saving (before, it was shown as enabled but silently dropped from the saved JSON).
182
+ - **Object-form entries are named by their key (behavior change, tightening)**: in the `cgrups` / `cpemis` / `crules` JSON of the form `{"<name>": …}`, an inner `name` field no longer renames the entry (before, `{"A":{"name":"B","isActive":"y"}}` granted `B` while the edit dialogs showed `A`). The array form `[{"name":"A","isActive":"y"}]` is unchanged. The permission-rule edit dialog also reads object-form values (`{"A":{"isActive":"y"}}`) the same way as the server, so such a rule is no longer shown as disabled (and revoked on save).
183
+ - **`/syncAndReplaceTabs` blank rows**: a row that carries no content outside the server-managed fields is rejected with `invalidRows`. Server-managed fields are `from`, `userId`, `timeCreate`, `userIdUpdate` and `timeUpdate` (the server overwrites them); a field value counts as content when it is a non-empty string, a finite number (`0` included) or a boolean (`""`, `null`, `[]` and `{}` do not).
184
+ - **Field values must be scalar (behavior change)**: every field of a row written by `updateTargets`, `updatePemis`, `updateGrups`, `updateUsers` or `/syncAndReplaceTabs` must be a string, number, boolean, `null` or absent. An object, array or other non-scalar value is rejected as a whole before anything is deleted (`saveRowFieldInvalid` for the admin pages, `invalidRows` for `/syncAndReplaceTabs`; the existing rows stay unchanged). No table has an object-typed field: `cgrups`, `cpemis` and `crules` are JSON **strings**. Before, a nested string such as `{ "description": { "value": "<script>" } }` slipped past the write validation and was silently rewritten when stored.
185
+ - **Edit dialogs decode authorization data exactly like the server (behavior change, tightening)**: `cgrups` / `cpemis` / `crules` may also be written in the array form `[{ "name": "A", "isActive": "y" }]` (an external application writing directly; `getPerm` / `getPermUserInfor` already honor it). The edit dialogs for user groups, group permissions and permission rules (and the two "belongs to" dialogs) used to look entries up by name, showed such entries as disabled, and saving revoked the authorization. They now decode both forms with the same function as the server, so what a dialog shows matches what is authorized, and saving keeps the authorization unchanged. Details: (1) in `cgrups` / `cpemis`, an entry with `isActive: "y"` but a missing or invalid `mode` (anything other than `"OR"` / `"AND"`) is not honored by the server; the dialogs now show it as disabled (before, object-form entries like that were shown as enabled and saving filled in `OR`, silently granting it) **[superseded on 2026-10-21 by the "Conflicting or ambiguous authorization data" item below: such an entry is now honored by the server as a restriction (it only intersects with the other entries and never grants on its own), and the dialogs show it as an enabled `AND` entry]**; (2) in the object form `{ "<name>": { "name": …, … } }` the outer key is the name and an inner `name` is ignored, also in the "belongs to" dialogs (before, saving there re-pointed the grant to the inner name); (3) the three direct editors always save the object form `{ "<name>": … }`, the two "belongs to" dialogs rewrite the stored string only when its decoded content changes; (4) see the next two items for the new merge rule (order independent) and for the case the object form cannot hold (the same name active as both `AND` and `OR`).
186
+ - **The result of merging `AND` entries no longer depends on their order (behavior change, 2026-10-21, ADR-033)**: in `cgrups` / `cpemis` / `crules` an entry that is **not listed counts as `n`**. For each target the result is: `y` ⇔ (if any active `OR` entry exists: at least one `OR` entry gives `y`) and (every active `AND` entry gives `y`); a level with no active entry at all gives `n`. Before, with no `OR` entry the first `AND` entry was the base and the other `AND` entries only constrained the targets they listed, so the stored order of two partial `AND` entries decided the result; with an `OR` entry present an `AND` entry that did not list a target did not constrain it. The same rule applies to the permissions inside a group and to the groups of a user, and the order of entries, object keys or array elements never matters. Two consequences follow (they were not spelled out in the ruling and need your confirmation): (1) repeating the same rule name inside one array-form `crules` now gives `y` only if every repeated entry is `y` (before the last entry won); (2) an `AND` group or permission that holds no rules at all counts as "all `n`": it removes every target granted by the `OR` entries (before it was ignored or zeroed everything depending on its position). **It can only take authorization away, never add it**, compared with the old rule (verified over 223,384 exhaustive cells). Users whose authorization used to rely on "an `AND` permission does not list this target" lose that target. **Before upgrading, audit your data**: export `{ users, grups, pemis, targets }` as JSON and run `node test/tools/authMergeReport.mjs --audit <export.json>`; it lists, per user, the targets the new rule takes away (`被收回`), whether the old result depended on the stored order, and groups/permissions with duplicate names. (Limitation: a rule whose name cannot be used as a property key, e.g. `{ "toString": null }`, made the old rule throw, so such a user effectively had **no** authorization; the audit skips that rule like the new server does, so those users' `被收回` figures are not the old server's real result.) The permission-rule edit dialog stays two-state (checked / unchecked): unchecking a target (stored as `n`) is the same as not listing it, and saving a dialog that wrote unlisted targets as explicit `n` no longer changes anything (under the old rule such a save could silently revoke access).
187
+ - **Data that the object form cannot hold is not rewritten (behavior change, fail-closed)**: when a `cgrups` / `cpemis` array (written directly by an external application) has the same name active as both `OR` and `AND`, the edit dialogs refuse to write it (the object form holds one mode per name, and keeping either side would add or remove authorization). One more case is refused the same way (2026-10-21, ADR-035): an active entry without a usable name. (A level whose active entries **all** have a missing or invalid `mode` was refused too, but since 2026-10-22 it can be saved: the dialogs write unchanged entries back with their original `mode`, so nothing more is granted; see the item on the edit dialogs below.) A direct editor with changes, or a "belongs to" dialog with a change on such a row, is not saved and shows a fixed message naming the affected names; the stored string is left untouched, and a dialog opened and saved without changes returns the original string. The data has to be corrected at its source. Data written by the dialogs themselves (the object form) is never affected. The dialogs no longer keep the order of the stored entries and no longer refuse an integer-like name such as a group called `1` (those protections only existed because the order used to matter; see the previous item).
188
+ - **List pages decode like the server**: the user, group and permission lists count and name the groups, permissions and rules of an array-form `cgrups` / `cpemis` / `crules` the same way as the server (before, the user list used the array index as the group name and the permission list counted 0 rules). Display only; nothing is written.
189
+ - **`/syncAndReplaceTabs` requires non-empty string names and ids (behavior change)**: for `grups` and `pemis` the `name` of every row, and for `targets` the `id` of every row, must be a non-empty string (as the admin pages already required). A missing value, an empty string, or a non-string (number, boolean, `null`) rejects the whole push with `invalidRows` before anything is deleted. Before, a group or permission with `name: ""` was stored and honored by `getPerm` / `getPermUserInfor`, but the edit dialogs treated it as invalid and removed it on the next save. `users` rows are not affected.
190
+ - **"Belongs to" dialogs open after adding a row, and pair rows by identity (bug fix, 2026-10-21)**: a user or group added in the admin pages starts with an empty `cgrups` / `cpemis` string. The two "belongs to" dialogs (group page "users", permission page "groups") decoded every row of the table and failed on the empty string, so as soon as one newly added row existed none of those dialogs could be opened. An empty or unparsable string is now treated as "no memberships" (the same as the server does); a row whose string was unparsable and whose membership you did not change is saved back unchanged (as long as that stored string was not changed by someone else while the dialog was open; the dialogs write the whole table back, so the usual last-writer-wins applies). In addition, when such a dialog saves, each of its rows is now matched to the stored user / group by its `id` (a group must also still have the same `name`) instead of by its position in the list, so a list that was re-ordered while the dialog was open can no longer write a membership to the wrong user or group. If a user or group shown in the dialog has been deleted (or a group renamed) in the meantime, nothing is saved and a message asks you to reload the page. The same re-matching by `id` applies when the direct editors (user groups, group permissions, permission rules) hand their result back to the list page, and the "select all / none / invert" buttons of the group page's user dialog now address rows by user `id` (user names may repeat). Note that these dialogs still write the whole table back: a user or group that someone else adds while the dialog is open is not part of the dialog and is removed by that save. **[Superseded on 2026-10-22: the dialogs now send the store snapshot taken at save time and change only the rows the administrator changed, and they carry a load baseline; see the item "The bundled pages bind the baseline when the edit draft is created" of the 2026-10-22 section above.]**
191
+ - **A rule name that cannot be a property key no longer fails the whole query (bug fix, 2026-10-21)**: `crules` is valid JSON even when an element of the array form has a `name` such as `{"toString": null}`, which JavaScript cannot turn into a key. Before, `getPerm` / `getPermUserInfor` threw for every user holding that permission and returned the engine message (`Cannot convert object to primitive value`) to the calling application. Now that element is ignored (it matches no target) and the rest of the rules are evaluated as usual; the same holds for the permission-rule edit dialog and the list pages. Any other failure while evaluating a user's rules is logged and answered with the fixed key `cannotGetUserRules`, never with the engine message.
192
+ - **Group and permission names must be unique (behavior change, 2026-10-21, F-02)**: authorization looks groups and permissions up by name (the server takes the first row with that name), so two rows with the same name made the result depend on the database row order. The admin pages already rejected duplicates; `/syncAndReplaceTabs` now rejects a push for `grups` or `pemis` with `invalidRows` (before anything is deleted, nothing changes) when two rows of the push share a `name`, or when a `name` is already used by a row of another `from` (rows of the same `from` are replaced by the push and do not count). Names are compared exactly: `Admin`, `admin` and `Admin ` are different names. The admin lists reject duplicates with the new key `nameDuplicate` (before: `saveRowFieldDuplicate`; users' `email` and targets' `id` are unchanged), and the server only refuses when the save creates or touches the duplicate (duplicates already in the database do not make a save sent to the API fail, so they can be repaired), and a save now always matches the stored rows of groups and permissions by `id` (before, by `name` unless the database already held two rows with the same name): a rename is an update that keeps the creator and creation time, and these single saves, which used to report success while leaving two rows with the same name or losing the rename, now give the intended result: renaming a row and adding a new row with its old name, deleting a row and adding a new row with the same name, and a chain of renames (`A`→`B` and `B`→`C`). Two rows of the submitted table with the same `id` are refused with `saveRowFieldDuplicate`. **Cleaning up existing duplicates** (nothing is deleted or renamed automatically): run `node server/tools/nameDupReport.mjs <export.json>` (read-only; the file is `{ users, grups, pemis, targets }`, each an array of rows) to list every same-name group and which `users.cgrups` / `grups.cpemis` refer to it; then rename or delete one of the rows in the admin pages. **References are matched by name and renaming does not update them (F-07 is not implemented)**: renaming or deleting one row makes every reference to that name point to the remaining row or to nothing, so list the references with the tool first and check each user's authorization afterwards (`getPermUserInfor` / `getPerm`). **The admin pages are stricter than the server**: the group / permission lists still mark every duplicate name and refuse to send the save until one of the rows is renamed or deleted, so with duplicates already in the database nothing else can be saved from the admin page until the duplicates are fixed (known gap, listed for a decision) **[resolved on 2026-10-21: the lists now use the same rule as the server — see the "Conflicting or ambiguous authorization data" item below]**.
193
+ - **The user mapping key must identify one user (behavior change, 2026-10-21, F-14)**: with `mappingBy: 'email'` or `'name'`, a login looks the user up in the permission system by that field. Before, when several active users matched, the first one in the database was taken, so the signed-in identity depended on the row order. Now more than one active match is refused (`ambiguousUserFromPerm`, logged with `mappingBy` and the number of matches but not the value; the login page redirects as for any refused login, and the data channel answers `permission denied`); no match still gives `cannotGetUserFromPerm`. To keep the key unique, `/syncAndReplaceTabs` for `users` now rejects the whole push with `invalidRows` when `mappingBy` is `'email'` or `'name'` and an active (`isActive: 'y'`) row has an empty or blank value in that field, repeats the value of another active row of the push, or uses a value held by an active row of another `from` (inactive rows are ignored; values are compared exactly). With the default `mappingBy: 'id'` nothing changes for `/syncAndReplaceTabs` (emails may stay empty or repeated there; **[perm-run04/05: one new check — a row whose `srcKey` equals the `id` of an admin-created user, or the `srcKey` of a row of another source, is refused with `loginIdCollision` whether or not the rows are active, see "Upgrade Notes" section 4]**) — but note that the admin user list itself still requires a non-empty, unique email on **every** row (active or not), so such rows have to be corrected before the admin page can save users **[superseded on 2026-10-21: only an email that the current save creates or touches is checked, see the "Conflicting or ambiguous authorization data" item below]**. **If you run with `mappingBy: 'email'` or `'name'`**, run `node server/tools/nameDupReport.mjs <export.json> --mapping-by email` (or `name`) first: users that are active with an empty or repeated key cannot sign in until fixed, and the next push from the owning application is rejected until the data is corrected. The backend `updateUsers` API only checks `email` (not `name`); the admin user list page itself also rejects duplicate names before saving, so only a direct API write can store two users with the same name, and with `mappingBy: 'name'` such users are refused at login rather than at save **[superseded on 2026-10-21: with `mappingBy: 'name'` or `'email'` the save now also checks that field of the active users, see below]**.
194
+ - **Same-table writes are serialized; two small fixes (2026-10-21)**: `/syncAndReplaceTabs` and the admin pages' whole-table save now take the same per-table lock, so the checks that keep group / permission names and the user mapping key unique can no longer be interleaved with the other writer (before, each took its own lock: a push and a save landing between each other's check and write could both succeed and leave two rows with the same name; a save made while a large push is running now waits for it). In `modeEditUsers: 'for:grups'`, a user row sent with a numeric id (for example `7` for the stored id `'7'`) used to have its group assignment silently skipped while the call reported success; it is now matched. A group / permission whose name is the **empty string** (only writable by `/syncAndReplaceTabs` before 2026-10-20) is still honored by the server, but the edit dialogs treat an empty name as invalid, so saving a dialog drops it and, if it was an `AND` limit, adds authorization: `node test/tools/authMergeReport.mjs --audit <export.json>` now lists such rows ("名稱為空字串或非字串之群組/權限") — rename them in the admin pages before relying on the dialogs **[superseded on 2026-10-21: the dialogs now keep such an entry as it is instead of dropping it, see below; renaming is still advisable]**.
195
+ - **Conflicting or ambiguous authorization data is resolved strictly, and the admin pages flag it (behavior change, tightening, 2026-10-21, ADR-035)**: when stored data contradicts itself or cannot be read in a single way, the server takes the strictest reading, and the admin pages show a warning icon (a triangle with an exclamation mark) at the left of the affected cell, with a tooltip that says exactly what is wrong and what the system does about it. Three cases change what a user is authorized for:
196
+ 1. **A reference to a group or permission that does not exist** (deleted, renamed or misspelled) used to be ignored. It is now still counted with the `mode` it was written with, but grants nothing: an `OR` reference adds nothing, an `AND` reference leaves the result empty. Example: a user whose `cgrups` lists group `G1` as `OR` and group `G2` as `AND` is authorized by what both groups share; before, deleting `G2` made the user keep everything `G1` grants (the `AND` limit silently disappeared); now the user has **nothing** until the reference is removed or `G2` is restored.
197
+ 2. **A reference whose name matches several rows** (duplicate group or permission names stored before duplicates were rejected, see F-02 above) used to use the first row in the table, so the result depended on the database row order. It now uses only what all the matching rows grant in common (the intersection), whatever the order.
198
+ 3. **An entry of `cgrups` / `cpemis` whose `mode` is missing or is not exactly `"OR"` or `"AND"`** (for example `"or"`, `"XOR"`, a number, or the short form `{"G1": "y"}`) used to be ignored. It is now a **restriction entry**: it still takes part, but only as an `AND`-style limit. It is intersected with the other entries of the same level and **never grants anything on its own** (it is never the base of the intersection). When the level also has an entry with a valid `mode`, a restriction entry behaves exactly like an `AND` entry (for example, a user who has `G1` as `OR` and `G2` with a missing `mode` is authorized by what `G1` and `G2` grant in common; before, `G2` was ignored and the user kept everything `G1` grants). When the level has **no** entry with a valid `mode` (every active entry has a missing or invalid `mode`, or the level has no active entry at all), the level grants **nothing**, the same as before (for example a `cgrups` that holds only `{"G1": {"isActive": "y"}}` or only the short form `{"G1": "y"}`).
199
+
200
+ Compared with the previous release these rules **only take authorization away, with no exception** (the audit below lists any user who would be granted something new under `多給`; it is expected to be empty). One detail differs from the literal wording of the rule "treat it as `AND`": if the only active entry of a level (the groups of a user, or the permissions of a group) has a missing or invalid `mode`, reading it literally as `AND` would make it the base of the intersection and grant its content where the previous release granted nothing. The stricter reading was chosen instead (restriction entry, above), by the maintainer's own decision under the rule "when a datum can be read in several ways, take the intersection of the readings"; this one case is **pending confirmation by the project owner** and is tracked in `spec/設計要點與取捨.md` (ADR-035, decision e and Consequences). Because the edit dialogs show such an entry as an enabled `AND` entry, saving a level whose active entries **all** have a missing or invalid `mode` would turn the entry into the sole base and grant more than before; the dialogs write such an entry back with its original `mode` when the administrator did not change it, so that case can be saved without granting more (before 2026-10-22 it was refused; the warning icon stays). The same warning icons also mark: a group listed twice with both `OR` and `AND` (the editors refuse to save it), a `crules` array that lists one target twice with both checked and unchecked (it is treated as unchecked, see the item on `AND` ordering above), an empty name, and an entry without a usable name.
201
+ - **Audit your data before upgrading (nothing is changed by these commands)**: export `{ users, grups, pemis, targets }` as JSON (each an array of rows, for example the result of `select` on the four tables) and run `node test/tools/authMergeReport.mjs --audit <export.json>` (add `--json` for a machine-readable result). For every user it lists the targets taken away compared with the previous release (`被收回`), the references that cause the new rules to take something away (`觸發`: a reference that does not exist `懸空`, several rows with the same name `同名多列`, a missing or invalid mode `mode 非法`), the users who would be granted something they did not have (`多給`, expected to be empty; a non-empty result means the rules or the data need a report), the groups / permissions with duplicate names, and the groups / permissions with an empty name. Then run `node server/tools/nameDupReport.mjs <export.json> [--mapping-by id|email|name]` (read-only) to list every same-name group or permission together with the users or groups that refer to it, and the users whose mapping key is empty or repeated. If you run with `mappingBy: 'email'` or `'name'`, check the mapping field first (an active user with an empty or repeated key cannot sign in, and a later push from the owning application is rejected). Nothing is deleted or renamed automatically; clean the data in the admin pages, then check each user's authorization (`getPermUserInfor` / `getPerm`).
202
+ - **The edit dialogs no longer drop what they cannot show (behavior change, 2026-10-21)**: saving a dialog used to rewrite the stored JSON from the rows it displayed, so an active reference to a group or permission that no longer exists (which is now an `AND` limit that leaves the result empty) was silently removed and the limit with it. Now: (1) the user-groups and group-permissions dialogs list such a reference as an extra row after the normal rows (the name cannot be edited; the mode and "use" check box can, so an administrator can disable the reference to resolve it) and write it back on save; a disabled reference of that kind is not shown and is not kept. (2) An entry with a missing or invalid `mode` (including the short form `{"G1": "y"}`) is shown as an enabled `AND` entry; **if the administrator does not change that row it is written back with its original `mode` (missing stays missing)**, so saving never turns a restriction entry into a real `AND` base and what the user is authorized for does not change (even when the administrator disables the only entry with a valid `mode` in the same edit). Changing the row's mode or "use" check box writes what the dialog shows. When **every** active entry of the level has a missing or invalid `mode`, the dialog can still be saved (since 2026-10-22; it was refused before): unchanged entries are written back as they were, so nothing more is granted, and the warning icon stays. Limitation: re-selecting `AND` on such a row is treated as unchanged (it is already shown as `AND`), so an administrator cannot turn it into a real `AND` entry that way; choose `OR`, or fix the data at its source. (3) An entry whose name is the empty string is kept as it is. (4) An active entry without a usable name (for example an array element without `name`) cannot be written in the object form, so saving is refused with the fixed message of the earlier item "Data that the object form cannot hold is not rewritten" (now also covering this case), and a line at the top of the dialog points at it. (5) When several rows of the group or permission table share one name (historical duplicates), the dialog lists each row, switches all rows of that name together (they are one reference), and refuses to save if they are nevertheless not set identically; saving writes the entry once. The "belongs to" dialog of a permission locates group rows by id (not by name). (6) The "belongs to" dialogs (group page "users", permission page "groups") change only the one entry that belongs to the group or permission being edited and leave every other entry of the user / group, including the references above, exactly as they were.
203
+ - **Historical duplicates no longer block saving; users are checked only where the save creates the problem (behavior change, 2026-10-21)**: the group / permission lists used to mark every duplicate name and refuse to send the save, although the server already accepted duplicates that were already in the database (see F-02 above). They now use the server's rule: a duplicate that the current save creates or touches (a new row, a rename onto an existing name, a third row with the same name) is refused as before, while a duplicate that was already stored is only marked with the warning icon and does not stop the save, so it can be repaired. For users, the same rule applies to `email` and `name` in the list page, and the server no longer checks `email` on the **whole** table: it checks only new rows and rows whose `email` changed (non-empty and unique), so historical empty or repeated emails no longer make every save fail (also in `modeEditUsers: 'for:grups'`). With `mappingBy: 'name'` or `'email'` the server additionally checks that field of the **active** users, again only where the save creates or touches the problem (a new row, a changed value, or an inactive user turned active: non-empty after trimming, and unique among active users); with the default `mappingBy: 'id'`, `name` is still not checked. A refused save returns the usual `saveRowFieldInvalid` / `saveRowFieldDuplicate`, changes nothing, and the log records the reason but never the email or name.
204
+ - **Deleting a group or permission that others refer to with `AND` asks first (2026-10-21)**: in the group list, deleting a group that at least one user lists with `AND` (an entry whose mode is missing or invalid is a restriction entry and is counted too; the count is an upper bound) opens a confirmation box that says how many users lose their authorization; "No" keeps the group. The permission list does the same for groups that refer to the deleted permission with `AND`. Deleting one of several same-named rows (historical duplicates) can also widen what the referring users or groups are authorized for, because the result for such a name is the intersection of its rows; the same box then says how many referrers may gain authorization. Deleting a group or permission that nobody refers to with `AND`, and that is not one of several same-named rows referred to by anybody, behaves as before and shows no box. *Superseded on 2026-10-22: the box text and the behavior of a deletion changed, and a rename now updates the references; see the next two items.*
205
+ - **Renaming a group or permission updates the references to it (behavior change, 2026-10-22, F-07 phase 1)**: when `updateGrups` / `updatePemis` (the admin pages) or `/syncAndReplaceTabs` renames a group or permission, the `cgrups` of the users (or the `cpemis` of the groups) that list the old name are rewritten to the new name in the same write, so authorization is unchanged (before, the references silently became dangling: an `AND` reference that stopped matching could even widen a user's authorization). A rename is recognised by the row id (same id, different name); swaps (`A`↔`B`) and chains (`A`→`B`, `B`→`C`) are applied simultaneously. The save is refused as a whole, changing nothing (fixed message keys `renameCascadeDupGroup`, `renameCascadeNameInUse`, `renameCascadeConflict`, `renameCascadeUnsupported`; `/syncAndReplaceTabs` answers `invalidRows`, the reason is in the log) when: the renamed row shares its old name with other rows (historical duplicates) and that name is still referenced; the new name is still referenced by active entries but no group / permission carries it; one stored string contains both the old and the new name; or a string that would have to be rewritten contains a value that cannot be re-encoded safely (a JSON5 `Infinity` / `NaN` number, which `JSON.stringify` would turn into `null`). **Set `opt.cascadeRename: false` to keep the old behavior** (references are not updated and become dangling, which stays strict and flagged as above). The admin pages show a confirmation box before saving a rename with the number of users (groups) whose references will be updated; users whose `from` is not empty belong to an external application and their `cgrups` may be overwritten again by that application's next synchronization **[since 2026-10-22 only when that push carries a `cgrups` key; a push without the key keeps the stored value, see the 2026-10-22 section above]**. Rewritten strings are re-encoded as JSON (comments and quoting style are not kept; strings that need no change are untouched); the object form stays an object and the array form stays an array. The rewrite is written by the acting admin (`userIdUpdate` / `timeUpdate` of the rewritten rows).
206
+ - **Deleting a group, permission or target removes the references to it (behavior change, 2026-10-22)**: deleting a group removes it from the `cgrups` of every user, deleting a permission removes it from the `cpemis` of every group, and deleting a target (or changing its id, which is a delete plus an add) removes it from the `crules` of every permission, in the same write (admin pages and `/syncAndReplaceTabs`). Before, the references were kept and ignored ("not found, ignored"), and a group re-created with the same name silently took them over; now a re-created name starts with no references. **A reference to a deleted `AND` group is removed together with its restriction, so the referring users may gain authorization**; the confirmation box before the delete (group list, permission list) and before saving a target change says how many users / groups / permissions lose the reference and how many of them referred to it with `AND`. Deleting one of several same-named rows (historical duplicates) keeps the references (the name still resolves) and still asks as described above. "Delete only if nobody refers to it" is not implemented on purpose (it could involve many accounts, each to be fixed by hand).
207
+ - **Rows written without an `id` are matched by name (`updateGrups`, `updatePemis`, `/syncAndReplaceTabs`)**: a row with no `id` gets a new random id on every write (the old row is deleted and a new one added), so, for the purpose of references only, it is matched to an existing row of the same name (within the same source for `/syncAndReplaceTabs`): same name means the same group or permission and its references are kept; a row whose name matches no existing row is a new group or permission (and an existing row that nothing matches is a deletion, so references to it are removed).
208
+ - **Within one source (`from`), `/syncAndReplaceTabs` identifies groups and permissions by id**: a pushed row with the same id and a different name is a rename (references follow); an id that is no longer pushed is a deletion (references are removed). Rows pushed without an `id` (the server then generates a random id on every push) are matched to the source's existing rows by name, so a push that keeps the same names removes nothing; but a rename without ids cannot be recognised and counts as delete plus add. **If your application renumbers its ids on every push, references to the renumbered groups / permissions will be removed on each push, so keep ids stable** (or push without ids).
209
+ - **Cleaning up references that are already dangling (one-off, run it once when upgrading)**: `node g_authRefsClean.mjs` lists them (nothing is changed); `node g_authRefsClean.mjs --apply` backs up the database and the three tables that hold references (`--backup <file>`), then removes the dangling entries, with the same effect as the old "not found, ignored" (an `AND` reference to a missing group no longer empties the result). The offline version for an export file is `node server/tools/authRefsAudit.mjs <export.json> [--json] [--clean-out <file>]`. Dangling references that remain stay strict (counted with their `mode`, granting nothing) and flagged with the warning icon.
210
+ - **Locks**: saving groups, permissions or targets that renames or deletes something now also waits for the write lock of the table that refers to them (`users`, `grups`, `pemis`), always taken in the order targets, permissions, groups, users (one function, `withTabLocks`); saves that rename or delete nothing take only their own table's lock, as before.
211
+
212
+ ### Upgrading to w-serv-orm 1.1.11 / w-syslog 1.0.33 (2026-10-14)
213
+ - **Numeric ids are stored as strings**: a numeric row id sent to `/syncAndReplaceTabs` (or to `updatePemis`, `updateGrups`, `updateUsers`) is stored as its string form (`123` is stored as `'123'`) and the success response lists it as a string. Before, the number was replaced by a generated id while the response still showed the number. A number and its string form are the same id; `NaN` and `Infinity` count as no id (a new id is generated). `updateTargets` still accepts string ids only and rejects other ids with `saveRowFieldInvalid`.
214
+ - **Ids owned by another source are rejected**: if an id pushed to `/syncAndReplaceTabs` already belongs to another `from`, the whole push is rejected with `invalidRows` before anything is deleted (before, the row was skipped while the push still returned success). External applications must use ids that do not overlap with other sources.
215
+ - **Logging**: with w-syslog 1.0.33, a log write failure no longer terminates the process (it is reported on stderr), and `WWebPerm` throws at construction when `opt.logFd` cannot be created.
216
+ - **Bundled usage**: the statistics worker file is located on first use from the installed package (`server/staLogs/staLogsCore.shellWorker.mjs`), so `dist/w-web-perm.umd.js` and downstream bundles can run the statistics; the package must be installed with its `server` folder.
217
+
218
+ ### Upgrading to w-serv-orm 1.1.10 / w-serv-hapi 1.1.6 (2026-10-14)
219
+ - **Removed options**: `opt.useCheckUser`, `opt.getUserById` and `opt.useExcludeWhenNotAdmin` were removed together with w-serv-orm 1.1.9. Passing any value other than `undefined`, `null` or `false` now throws when constructing `WWebPerm` (the message comes from w-serv-orm `checkOptRemoved`) and is logged to srLog. Delete these keys; users of the data channel are authorized by `verifyConn` (`verifyClientUser`), no replacement option is needed.
220
+ - **Data channel is select-only (behavior change)**: only `<table>.select` of `targets`, `pemis`, `grups` and `users` is exposed (`methodsExec: ['select']`, and `authorizeExec` allows select only). Calling raw `insert`, `save`, `del` or `delAll` now returns `invalid func[<table>.<method>]`. Clients that wrote tables directly must call `updateTargets`, `updatePemis`, `updateGrups` or `updateUsers`, which apply the save lock, serialization, validation and whole-table diff.
221
+ - **Write validation**: rows to be written are validated before anything is deleted or written. A string value that XSS cleaning would rewrite (for example `<` followed by a letter) is rejected with `htmlMarkupNotAllowed`; an id longer than 1978 UTF-8 bytes is rejected with `saveRowFieldInvalid` (admin lists) or `invalidRows` (`syncAndReplaceTabs`). Unexpected database write failures return `saveWriteFail`; the ORM messages are written to srLog only.
222
+ - **Process-level errors**: w-serv-hapi 1.1.6 no longer registers `unhandledRejection` or `uncaughtException` handlers, and `WWebPerm` does not register them either; the deployment decides its own process policy.