w-web-perm 1.1.13 → 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 (107) hide show
  1. package/README.md +70 -6
  2. package/dist/css/{app.f09c7433.css → app.f24c5e50.css} +1 -1
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.033deb22.js +1 -0
  5. package/dist/js/app.033deb22.js +1 -0
  6. package/dist/js/{chunk-vendors.ece5a361.js → chunk-vendors-legacy.b0bbb5ac.js} +4 -4
  7. package/dist/js/{chunk-vendors-legacy.ece5a361.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 +11 -6
  11. package/docs/global.html +11316 -5113
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +114 -334
  14. package/docs/server_cascadeWrite.mjs.html +5 -26
  15. package/docs/server_dataChannel.mjs.html +3 -3
  16. package/docs/server_keyCheck.mjs.html +150 -23
  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 +246 -62
  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 +27 -20
  23. package/docs/server_tools_authRefsAudit.mjs.html +2 -2
  24. package/docs/server_tools_authRefsClean.mjs.html +2 -2
  25. package/docs/server_tools_fromTools.mjs.html +2 -2
  26. package/docs/server_tools_isMain.mjs.html +2 -2
  27. package/docs/server_tools_nameDupReport.mjs.html +40 -7
  28. package/docs/server_validateRows.mjs.html +2 -2
  29. package/docs/server_wrapInjected.mjs.html +2 -2
  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 +2 -2
  36. package/docs/src_js_draftSync.mjs.html +83 -7
  37. package/docs/src_js_eachOwn.mjs.html +2 -2
  38. package/docs/src_js_editorRefRows.mjs.html +2 -2
  39. package/docs/src_js_externalRows.mjs.html +34 -10
  40. package/docs/src_js_externalUi.mjs.html +4 -8
  41. package/docs/src_js_findRefRows.mjs.html +2 -2
  42. package/docs/src_js_findTouchedDup.mjs.html +2 -2
  43. package/docs/src_js_fmtLossyMsg.mjs.html +2 -2
  44. package/docs/src_js_fromAudit.mjs.html +2 -2
  45. package/docs/src_js_getAddImpact.mjs.html +2 -2
  46. package/docs/src_js_getAuthWarn.mjs.html +2 -2
  47. package/docs/src_js_getCascadePreview.mjs.html +2 -2
  48. package/docs/src_js_getDelRefCount.mjs.html +2 -2
  49. package/docs/src_js_getEffMode.mjs.html +2 -2
  50. package/docs/src_js_getKpLossy.mjs.html +2 -2
  51. package/docs/src_js_getNameErrs.mjs.html +2 -2
  52. package/docs/src_js_getOwn.mjs.html +2 -2
  53. package/docs/src_js_getPropKey.mjs.html +2 -2
  54. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  55. package/docs/src_js_isAuthMode.mjs.html +2 -2
  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 +2 -2
  59. package/docs/src_js_refAudit.mjs.html +2 -2
  60. package/docs/src_js_refCascade.mjs.html +2 -2
  61. package/docs/src_js_refIdentity.mjs.html +500 -0
  62. package/docs/src_js_relDialogSend.mjs.html +2 -2
  63. package/docs/src_js_rowDigest.mjs.html +75 -145
  64. package/docs/src_plugins_dataReloadGuard.mjs.html +2 -2
  65. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  66. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  67. package/g_provideGrups.mjs +1 -1
  68. package/g_providePemis.mjs +1 -1
  69. package/g_provideTargets.mjs +1 -1
  70. package/g_provideUsers.mjs +1 -1
  71. package/package.json +2 -2
  72. package/server/WWebPerm.mjs +112 -332
  73. package/server/cascadeWrite.mjs +3 -24
  74. package/server/dataChannel.mjs +1 -1
  75. package/server/keyCheck.mjs +148 -21
  76. package/server/procCore.mjs +244 -60
  77. package/server/procLang.mjs +72 -4
  78. package/server/provideTabs.mjs +6 -4
  79. package/server/syncCore.mjs +461 -0
  80. package/server/syncKeep.mjs +25 -18
  81. package/server/tools/nameDupReport.mjs +38 -5
  82. package/src/components/LayoutContentGrups.vue +57 -8
  83. package/src/components/LayoutContentPemis.vue +57 -8
  84. package/src/components/LayoutContentTargets.vue +38 -7
  85. package/src/components/LayoutContentUsers.vue +57 -8
  86. package/src/components/VeGrupBlngUsers.vue +128 -20
  87. package/src/components/VePemiBlngGrups.vue +128 -20
  88. package/src/getPermUserInfor.mjs +10 -0
  89. package/src/js/applySaved.mjs +56 -0
  90. package/src/js/cellEditing.mjs +91 -0
  91. package/src/js/draftSync.mjs +81 -5
  92. package/src/js/externalRows.mjs +32 -8
  93. package/src/js/externalUi.mjs +2 -6
  94. package/src/js/refIdentity.mjs +428 -0
  95. package/src/js/rowDigest.mjs +73 -143
  96. package/src/schema/contracts/grups.mjs +8 -0
  97. package/src/schema/contracts/index.mjs +17 -0
  98. package/src/schema/contracts/pemis.mjs +8 -0
  99. package/src/schema/contracts/targets.mjs +8 -0
  100. package/src/schema/contracts/users.mjs +13 -0
  101. package/src/schema/tables/grups.mjs +4 -0
  102. package/src/schema/tables/pemis.mjs +4 -0
  103. package/src/schema/tables/targets.mjs +4 -0
  104. package/src/schema/tables/users.mjs +4 -0
  105. package/srv.mjs +19 -0
  106. package/dist/js/app-legacy.8af87052.js +0 -1
  107. package/dist/js/app.8af87052.js +0 -1
package/README.md CHANGED
@@ -94,21 +94,85 @@ instWWebPerm.on('error', (err) => {
94
94
  })
95
95
  ```
96
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
+
97
106
  ## Upgrade Notes
98
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
+
99
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)
100
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'`).
101
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).
102
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`).
103
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.
104
- - **`updateXxx(rows, { baseline })` (RPC contract, optional second argument)**: `updateTargets`, `updatePemis`, `updateGrups` and `updateUsers` accept `{ baseline }`, where `baseline` is `[[id, digest], ...]` (every element an array of two strings) describing the rows the edit is **based on**. Build it with `makeBaseline(rows, keys)` of `src/js/rowDigest.mjs` (`keys` is `ds[keyTable].keys` of `src/schema/index.mjs`); the digest is a content fingerprint of a row that leaves out `order` and the server-managed fields (a synchronization rewrites the time fields of every row of its source, and the save renumbers `order`, so neither may invalidate a draft). Inside the table lock the server compares the **whole table** with the baseline (changed in 2026-10-22 run11; see the next-but-one item): if any stored row has a different digest from the one the baseline lists for its `id`, or a row was added or removed, 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. Differences in `order` or in the server-managed fields alone are not changes, so a synchronization that rewrites its rows without changing their content does not make a save fail. A malformed `baseline` is refused with `saveRowFieldInvalid` before anything is read or written, and every other key of the client's second argument is ignored (only `baseline` is taken). **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`.**
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.
105
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.
106
- - **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) another administrator who only changes the order of rows (drag and drop) does not make a save fail, and the order of the later save wins; (3) `saveStale` text and the position of the reload button are covered by `test/e2e-external-rows.test.mjs` E2E-016 to E2E-024.
107
- - **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.
108
- - **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.
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.
109
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).
110
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.
111
- - **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". Known gaps, all leaving the draft in place and writing nothing: (a) saving again within about two seconds after your own successful save, before the push of that save arrives, is refused as if someone else had changed the table (reload and save again); (b) a "belongs to" dialog that was opened but not yet edited shows no banner when the table changes, and its later save is refused by the server; (c) a cell editor that is still open (text typed but not committed with Enter) can lose the typed text when a push arrives; (d) clearing a text cell stores `null` while an untouched empty cell holds `''`, and the fingerprint tells them apart, so another administrator clearing a field that was already empty counts as a change; (e) a change of `order` alone (drag and drop) is not a conflict, and the later save's order wins. These are planned to be fixed when the pages move to the shared baseline of w-serv-orm. (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`).
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`).
112
176
 
113
177
  ### Unreleased: objects carrying a numeric `length` key, and non-array `rows` (2026-10-20)
114
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.
@@ -126,7 +190,7 @@ instWWebPerm.on('error', (err) => {
126
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.]**
127
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.
128
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]**.
129
- - **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) — 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]**.
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]**.
130
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]**.
131
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:
132
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.
@@ -1 +1 @@
1
- .staleReloadBtn[data-v-f438a24c]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-f438a24c]:hover{background:#f2f2f2}.staleReloadBtn[data-v-7293db50]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-7293db50]:hover{background:#f2f2f2}.staleReloadBtn[data-v-589a7f30]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-589a7f30]:hover{background:#f2f2f2}.staleReloadBtn[data-v-2b7c269c]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-2b7c269c]:hover{background:#f2f2f2}.bg-gray-100[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-white[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.border[data-v-f9ffef80]{border-width:1px}.border-collapse[data-v-f9ffef80]{border-collapse:collapse}.border-solid[data-v-f9ffef80]{border-style:solid}.border-gray-200[data-v-f9ffef80]{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300[data-v-f9ffef80]{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.flex[data-v-f9ffef80]{display:-webkit-box;display:-ms-flexbox;display:flex}.focus\:ring-blue-500[data-v-f9ffef80]:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.font-medium[data-v-f9ffef80]{font-weight:500}.font-semibold[data-v-f9ffef80]{font-weight:600}.h-4[data-v-f9ffef80]{height:1rem}.hover\:bg-gray-50[data-v-f9ffef80]:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.items-center[data-v-f9ffef80]{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.justify-end[data-v-f9ffef80]{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.mb-8[data-v-f9ffef80]{margin-bottom:2rem}.ml-2[data-v-f9ffef80]{margin-left:.5rem}.mr-2[data-v-f9ffef80]{margin-right:.5rem}.mt-2[data-v-f9ffef80]{margin-top:.5rem}.overflow-x-auto[data-v-f9ffef80]{overflow-x:auto}.p-5[data-v-f9ffef80]{padding:1.25rem}.pb-2[data-v-f9ffef80]{padding-bottom:.5rem}.px-2[data-v-f9ffef80]{padding-left:.5rem;padding-right:.5rem}.px-6[data-v-f9ffef80]{padding-left:1.5rem;padding-right:1.5rem}.py-1[data-v-f9ffef80]{padding-top:.25rem;padding-bottom:.25rem}.py-3[data-v-f9ffef80]{padding-top:.75rem;padding-bottom:.75rem}.py-4[data-v-f9ffef80]{padding-top:1rem;padding-bottom:1rem}.relative[data-v-f9ffef80]{position:relative}.rounded[data-v-f9ffef80]{border-radius:.25rem}.rounded-lg[data-v-f9ffef80]{border-radius:.5rem}.shadow-md[data-v-f9ffef80]{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);-webkit-box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.space-x-4[data-v-f9ffef80]>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-8[data-v-f9ffef80]>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.text-blue-600[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-gray-500[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-700[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-900[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-left[data-v-f9ffef80]{text-align:left}.text-lg[data-v-f9ffef80]{font-size:1.125rem;line-height:1.75rem}.text-purple-600[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-sm[data-v-f9ffef80]{font-size:.875rem;line-height:1.25rem}.text-xs[data-v-f9ffef80]{font-size:.75rem;line-height:1rem}.transition-colors[data-v-f9ffef80]{-webkit-transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1);-webkit-transition-duration:.15s;transition-duration:.15s}.w-4[data-v-f9ffef80]{width:1rem}.w-full[data-v-f9ffef80]{width:100%}.staCtrl[data-v-f9ffef80]{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;gap:12px;padding:8px 0}.staCtrlRight[data-v-f9ffef80],.staCtrl[data-v-f9ffef80]{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.staCtrlRight[data-v-f9ffef80]{-webkit-box-align:center;-ms-flex-align:center;align-items:center;gap:16px}body,html{font-family:微軟正黑體,Microsoft JhengHei,MicrosoftJhengHeiRegular,Avenir,Helvetica,Arial,sans-serif;overflow-y:hidden}a,button,div,input,p,pre,span,textarea{font-family:inherit}.fade-enter-active{-webkit-animation:go 1s;animation:go 1s}.fade-leave-active{-webkit-animation:back 1s;animation:back 1s}@-webkit-keyframes go{0%{opacity:0}to{opacity:1}}@keyframes go{0%{opacity:0}to{opacity:1}}@-webkit-keyframes back{0%{opacity:1}to{opacity:0}}@keyframes back{0%{opacity:1}to{opacity:0}}
1
+ .staleReloadBtn[data-v-69b5484a]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-69b5484a]:hover{background:#f2f2f2}.staleReloadBtn[data-v-36de9118]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-36de9118]:hover{background:#f2f2f2}.staleReloadBtn[data-v-2f985738]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-2f985738]:hover{background:#f2f2f2}.staleReloadBtn[data-v-f8057b70]{margin-left:8px;padding:1px 10px;font-size:.8rem;color:#444;background:#fff;border:1px solid #ddd;border-radius:4px;cursor:pointer}.staleReloadBtn[data-v-f8057b70]:hover{background:#f2f2f2}.bg-gray-100[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-white[data-v-f9ffef80]{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.border[data-v-f9ffef80]{border-width:1px}.border-collapse[data-v-f9ffef80]{border-collapse:collapse}.border-solid[data-v-f9ffef80]{border-style:solid}.border-gray-200[data-v-f9ffef80]{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300[data-v-f9ffef80]{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.flex[data-v-f9ffef80]{display:-webkit-box;display:-ms-flexbox;display:flex}.focus\:ring-blue-500[data-v-f9ffef80]:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.font-medium[data-v-f9ffef80]{font-weight:500}.font-semibold[data-v-f9ffef80]{font-weight:600}.h-4[data-v-f9ffef80]{height:1rem}.hover\:bg-gray-50[data-v-f9ffef80]:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.items-center[data-v-f9ffef80]{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.justify-end[data-v-f9ffef80]{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.mb-8[data-v-f9ffef80]{margin-bottom:2rem}.ml-2[data-v-f9ffef80]{margin-left:.5rem}.mr-2[data-v-f9ffef80]{margin-right:.5rem}.mt-2[data-v-f9ffef80]{margin-top:.5rem}.overflow-x-auto[data-v-f9ffef80]{overflow-x:auto}.p-5[data-v-f9ffef80]{padding:1.25rem}.pb-2[data-v-f9ffef80]{padding-bottom:.5rem}.px-2[data-v-f9ffef80]{padding-left:.5rem;padding-right:.5rem}.px-6[data-v-f9ffef80]{padding-left:1.5rem;padding-right:1.5rem}.py-1[data-v-f9ffef80]{padding-top:.25rem;padding-bottom:.25rem}.py-3[data-v-f9ffef80]{padding-top:.75rem;padding-bottom:.75rem}.py-4[data-v-f9ffef80]{padding-top:1rem;padding-bottom:1rem}.relative[data-v-f9ffef80]{position:relative}.rounded[data-v-f9ffef80]{border-radius:.25rem}.rounded-lg[data-v-f9ffef80]{border-radius:.5rem}.shadow-md[data-v-f9ffef80]{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);-webkit-box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.space-x-4[data-v-f9ffef80]>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-8[data-v-f9ffef80]>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.text-blue-600[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-gray-500[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-700[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-900[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-left[data-v-f9ffef80]{text-align:left}.text-lg[data-v-f9ffef80]{font-size:1.125rem;line-height:1.75rem}.text-purple-600[data-v-f9ffef80]{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-sm[data-v-f9ffef80]{font-size:.875rem;line-height:1.25rem}.text-xs[data-v-f9ffef80]{font-size:.75rem;line-height:1rem}.transition-colors[data-v-f9ffef80]{-webkit-transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;-webkit-transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(.4,0,.2,1);-webkit-transition-duration:.15s;transition-duration:.15s}.w-4[data-v-f9ffef80]{width:1rem}.w-full[data-v-f9ffef80]{width:100%}.staCtrl[data-v-f9ffef80]{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;gap:12px;padding:8px 0}.staCtrlRight[data-v-f9ffef80],.staCtrl[data-v-f9ffef80]{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.staCtrlRight[data-v-f9ffef80]{-webkit-box-align:center;-ms-flex-align:center;align-items:center;gap:16px}body,html{font-family:微軟正黑體,Microsoft JhengHei,MicrosoftJhengHeiRegular,Avenir,Helvetica,Arial,sans-serif;overflow-y:hidden}a,button,div,input,p,pre,span,textarea{font-family:inherit}.fade-enter-active{-webkit-animation:go 1s;animation:go 1s}.fade-leave-active{-webkit-animation:back 1s;animation:back 1s}@-webkit-keyframes go{0%{opacity:0}to{opacity:1}}@keyframes go{0%{opacity:0}to{opacity:1}}@-webkit-keyframes back{0%{opacity:1}to{opacity:0}}@keyframes back{0%{opacity:1}to{opacity:0}}
package/dist/index.tmp CHANGED
@@ -1,4 +1,4 @@
1
1
  <!doctype html><html xmlns="http://www.w3.org/1999/xhtml" lang="zh-tw"><head><meta http-equiv="content-type" content="text/html; charset=UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,minimal-ui"><meta http-equiv="X-UA-Compatible" content="ie=edge"><meta name="referrer" content="strict-origin"><title></title><link _href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" rel="stylesheet"><link _href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.2/css/all.min.css" rel="stylesheet"><script>window.___pmwperm___={
2
2
  urlRedirect: '{urlRedirect}',
3
3
  language: '{language}',
4
- }</script><script defer="defer" src="{sfd}/js/chunk-vendors.ece5a361.js" type="module"></script><script defer="defer" src="{sfd}/js/app.8af87052.js" type="module"></script><link href="{sfd}/css/chunk-vendors.3c580e72.css" rel="stylesheet"><link href="{sfd}/css/app.f09c7433.css" rel="stylesheet"><script defer="defer" src="{sfd}/js/chunk-vendors-legacy.ece5a361.js" nomodule></script><script defer="defer" src="{sfd}/js/app-legacy.8af87052.js" nomodule></script></head><body style="font-family:'Microsoft JhengHei','Avenir','Helvetica', 'Arial', 'sans-serif'; padding:0px; margin:0px;"><noscript><strong>We're sorry but w-component-vue doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
4
+ }</script><script defer="defer" src="{sfd}/js/chunk-vendors.b0bbb5ac.js" type="module"></script><script defer="defer" src="{sfd}/js/app.033deb22.js" type="module"></script><link href="{sfd}/css/chunk-vendors.3c580e72.css" rel="stylesheet"><link href="{sfd}/css/app.f24c5e50.css" rel="stylesheet"><script defer="defer" src="{sfd}/js/chunk-vendors-legacy.b0bbb5ac.js" nomodule></script><script defer="defer" src="{sfd}/js/app-legacy.033deb22.js" nomodule></script></head><body style="font-family:'Microsoft JhengHei','Avenir','Helvetica', 'Arial', 'sans-serif'; padding:0px; margin:0px;"><noscript><strong>We're sorry but w-component-vue doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>