memhouse 0.18.12 → 0.20.1

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 (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. package/public/assets/index-CnWzrjF3.js +0 -78
package/memhouse/share.js CHANGED
@@ -24,6 +24,8 @@ const SCOPE_COLUMNS = {
24
24
  folder: { column: () => 'folder', quote: true },
25
25
  host: { column: () => 'host', quote: true },
26
26
  source: { column: () => 'source', quote: true },
27
+ // Schema 3 names the editor `harness`; `source=` means the same there.
28
+ harness: { column: () => 'harness', quote: true },
27
29
  // `sessions` has no `ts`; its clock is `created_at`. One literal predicate cannot span
28
30
  // the rooms, so time scopes render per room.
29
31
  since: { column: (room) => (room === 'sessions' ? 'created_at' : 'ts'), op: '>=' },
@@ -61,4 +63,688 @@ function policyName(user, room) {
61
63
  return `mh_share_${user}_${room}`;
62
64
  }
63
65
 
64
- module.exports = { SCOPE_COLUMNS, parseScope, scopePredicate, policyName };
66
+ // ── schema 3 (memhouse 0.20) ───────────────────────────────────────────────────────────────
67
+ // A share is SELECT on exactly the member's six conversation tables (harness-sessions-sql's profile) —
68
+ // never a wildcard (a 0.18 full share's `<m>_*` also reached meta, events and the stat tables) and
69
+ // never the archive database. A scope is a row policy on each of the six. Revoking takes back the
70
+ // grants AND the policies: a policy left behind would quietly narrow a later re-share.
71
+
72
+ const V3_TABLES = ['session_parts', 'session_usage', 'session_links', 'session_lifecycle', 'calls', 'sessions'];
73
+ // The scope columns each table has; a scope on a column a table lacks lets no row of it through
74
+ // (fail closed): a project-scoped reader sees no usage rows (the sessions row carries the totals) and
75
+ // a time-scoped one no links. A row whose ts is null never matches a time scope either.
76
+ const SCOPE_HAS = {
77
+ project: ['session_parts', 'calls', 'sessions'], folder: ['session_parts', 'calls', 'sessions'],
78
+ time: { sessions: 'started', session_parts: 'ts', session_usage: 'ts', calls: 'ts', session_lifecycle: 'ts' },
79
+ };
80
+ const LEGACY_ROOMS = ['messages', 'tool_calls', 'sessions'];
81
+ // The first schema-3 layout of the 0.20 development builds: a share made there granted <m>_turns
82
+ // (with <m>_calls and <m>_sessions, which the profile reuses by name) and filtered it.
83
+ const DEV_ROOMS = ['turns'];
84
+
85
+ /** A scope's predicate for one schema-3 table. `session=` takes a ref (its store included) or a bare id. */
86
+ function scopePredicateV3(scope, table, sqlStr, parseSessionRef) {
87
+ return scope.map(({ key, value }) => {
88
+ if (key === 'since' || key === 'until') {
89
+ const col = SCOPE_HAS.time[table];
90
+ return col ? `${col} ${key === 'since' ? '>=' : '<'} parseDateTimeBestEffort(${sqlStr(value)})` : '0';
91
+ }
92
+ if ((key === 'project' || key === 'folder') && !SCOPE_HAS[key].includes(table)) return '0';
93
+ if (key === 'source' || key === 'harness') return `harness = ${sqlStr(value)}`;
94
+ if (key === 'session') {
95
+ if (!value.includes(':')) return `session_id = ${sqlStr(value)}`;
96
+ const r = parseSessionRef(value);
97
+ const parts = [`harness = ${sqlStr(r.harness)}`, `session_id = ${sqlStr(r.session_id)}`];
98
+ if (r.host != null && r.harness_path != null) parts.push(`host = ${sqlStr(r.host)}`, `harness_path = ${sqlStr(r.harness_path)}`, `incarnation = ${sqlStr(r.incarnation || '')}`);
99
+ return `(${parts.join(' AND ')})`;
100
+ }
101
+ return `${SCOPE_COLUMNS[key].column(table)} = ${sqlStr(value)}`;
102
+ }).join(' AND ');
103
+ }
104
+
105
+ // Names reach the DDL below as identifiers. Each is checked against the one shape memhouse
106
+ // creates (as house.assertUsableName) and then quoted, so a name read back from the server
107
+ // (system.users, a meta record) can neither break the statement nor extend it.
108
+ const NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
109
+ function assertName(name, what) {
110
+ if (typeof name !== 'string' || !NAME_RE.test(name)) throw new Error(`'${name}' cannot be a ${what} — expected [A-Za-z][A-Za-z0-9_]*`);
111
+ return name;
112
+ }
113
+ /** A quoted ClickHouse identifier: backticks, with backslash and backtick escaped. */
114
+ function ident(name) { return `\`${String(name).replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\``; }
115
+ /** A checked, quoted `db.table` (each part one memhouse makes). */
116
+ function qual(fq) {
117
+ const parts = String(fq).split('.');
118
+ if (parts.length !== 2) throw new Error(`'${fq}' is not a db.table name`);
119
+ return parts.map((x) => ident(assertName(x, 'table name part'))).join('.');
120
+ }
121
+
122
+ /**
123
+ * The statements of a schema-3 share, in a fail-closed order: a scoped share writes its filters
124
+ * BEFORE any grant; the 0.18 wildcard is taken back before the table grants (it would reach past
125
+ * the filters, and past the six tables); a full share drops leftover filters only after it is
126
+ * granted (until then they narrow, never widen). Returns [{ sql, phase }], phase 'filter' | 'grant'
127
+ * | 'clear'. Throws on a name that is not one memhouse makes.
128
+ */
129
+ function shareV3Plan({ db, member, user, scope, sqlStr, parseSessionRef }) {
130
+ assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
131
+ const tbl = (t) => `${ident(db)}.${ident(`${member}_${t}`)}`;
132
+ const steps = [];
133
+ if (scope) for (const t of V3_TABLES) steps.push({ phase: 'filter', sql: `CREATE ROW POLICY OR REPLACE ${ident(policyName(user, t))} ON ${tbl(t)} USING ${scopePredicateV3(scope, t, sqlStr, parseSessionRef)} TO ${ident(user)}` });
134
+ // Exact tables only. No REVOKE on `<m>_*` here: a prefix wildcard also matches another
135
+ // member's tables when that member's name starts with this one's (bob, bob_smith) and would
136
+ // strip the reader's grants there, or that member's own. A 0.18 wildcard grant still held is
137
+ // taken back by the admin's move (moveSharePlan), only when the reader holds it.
138
+ steps.push(...V3_TABLES.map((t) => ({ phase: 'grant', sql: `GRANT SELECT ON ${tbl(t)} TO ${ident(user)}` })));
139
+ if (!scope) for (const t of V3_TABLES) steps.push({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${ident(policyName(user, t))} ON ${tbl(t)}` });
140
+ return steps;
141
+ }
142
+
143
+ /**
144
+ * Revoking a schema-3 share: every grant first — the six tables, the 0.18 rooms a share made before
145
+ * the conversion still names in <db>, and the development layout's <m>_turns — then every filter,
146
+ * the 0.18 and development ones included. Exact
147
+ * names only: a 0.18 wildcard grant (`<m>_*`) is taken back by wildcardRevoke, and only when the
148
+ * reader holds it and it matches nothing but this member's tables (wildcardOnRevoke). (The rooms renamed into the
149
+ * archive are the admin's: a member holds no rights there, so `migrate --v3-shares` clears them.)
150
+ */
151
+ function revokeV3Plan({ db, member, user }) {
152
+ assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
153
+ const tbl = (t) => `${ident(db)}.${ident(`${member}_${t}`)}`;
154
+ const NEW = V3_TABLES.map((t) => `${db}.${member}_${t}`);
155
+ const oldRooms = LEGACY_ROOMS.map((r) => `${db}.${member}_${r}`).filter((x) => !NEW.includes(x));
156
+ return [
157
+ ...V3_TABLES.map((t) => ({ phase: 'grant', sql: `REVOKE SELECT ON ${tbl(t)} FROM ${ident(user)}` })),
158
+ ...oldRooms.map((fq) => ({ phase: 'grant', sql: `REVOKE SELECT ON ${qual(fq)} FROM ${ident(user)}` })),
159
+ ...DEV_ROOMS.map((t) => ({ phase: 'grant', sql: `REVOKE SELECT ON ${tbl(t)} FROM ${ident(user)}` })),
160
+ ...V3_TABLES.map((t) => ({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${ident(policyName(user, t))} ON ${tbl(t)}` })),
161
+ ...legacyPolicyDrops({ db, member, user, keep: NEW }),
162
+ ...DEV_ROOMS.map((t) => ({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${ident(policyName(user, t))} ON ${tbl(t)}` })),
163
+ ];
164
+ }
165
+
166
+ /** Taking back a 0.18 wildcard share (`<m>_*`). Call it only for a reader who holds that grant,
167
+ * when it matches nothing but this member's tables (wildcardOnRevoke). */
168
+ function wildcardRevoke({ db, member, user }) {
169
+ assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
170
+ return { phase: 'grant', sql: `REVOKE SELECT ON ${db}.${member}_* FROM ${ident(user)}` };
171
+ }
172
+
173
+ /**
174
+ * May a schema-3 share be written next to what the reader holds? `heldWildcard`: true when the
175
+ * reader still holds the member's 0.18 wildcard, false when they do not, null when that could
176
+ * not be read. Only a known false proceeds: a share beside the wildcard would not narrow it, and
177
+ * an unread answer is no evidence either way.
178
+ */
179
+ function shareGate(heldWildcard) {
180
+ if (heldWildcard === false) return null;
181
+ return { refuse: heldWildcard === true ? 'held' : 'unknown' };
182
+ }
183
+
184
+ /**
185
+ * Members whose names collide with `member`'s in one house: one starts with the other's name and
186
+ * an underscore (bob, bob_smith). A member's grant is the prefix wildcard `<db>.<m>_*`, so the
187
+ * shorter name's grant also reaches the longer name's tables, to read and to write, and sometimes
188
+ * the other way round (wildcardOnRevoke). `tables` is the house's table names; a member is a prefix
189
+ * of a table ending in _meta, _session_parts, _turns or _messages.
190
+ */
191
+ function memberCollisions(member, tables) {
192
+ const members = new Set();
193
+ for (const t of tables) { const m = /^(.+)_(meta|session_parts|turns|messages)$/.exec(String(t)); if (m) members.add(m[1]); }
194
+ return [...members].filter((x) => x !== member && (x.startsWith(`${member}_`) || member.startsWith(`${x}_`))).sort();
195
+ }
196
+
197
+ // The names memhouse gives a member's tables in the house, as the part after `<m>_`: its tables,
198
+ // the copies migrate keeps of a 0.18 room while it moves one (`<room>__migrating`) and after
199
+ // (`<room>_pre_epoch`), and the scratch table share's probe makes and drops (`_mh_probe_<pid>`).
200
+ const MEMBER_TABLES = [...new Set([...V3_TABLES, ...LEGACY_ROOMS, ...DEV_ROOMS, 'meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats',
201
+ ...LEGACY_ROOMS.flatMap((r) => [`${r}_pre_epoch`, `${r}__migrating`])])];
202
+ const isMemberTable = (rest) => MEMBER_TABLES.includes(rest) || /^_mh_probe_\d+$/.test(rest);
203
+ const startsMemberTable = (p) => [...MEMBER_TABLES, '_mh_probe_1'].some((n) => n.startsWith(`${p}_`));
204
+
205
+ /**
206
+ * A reader's 0.18 wildcard `<db>.<member>_*` when the member revokes them. A REVOKE on it takes
207
+ * the reader's grant on every table it matches, whoever's table that is. Other members whose names
208
+ * collide with this one's come from the house's tables (memberCollisions) and from its users,
209
+ * since a member with grants but no tables, or whose tables this credential cannot see, has no
210
+ * table to find it by. The wildcard matches the tables of:
211
+ * - a longer name, all of them: bob_* matches bob_smith's;
212
+ * - a shorter name when the rest of `member` starts one of its table names: bob_session_*
213
+ * matches bob's bob_session_parts, while bob_smith_* matches none of bob's (ClickHouse 26.7:
214
+ * REVOKE ON mem.bob_smith_* leaves the rest of mem.bob_* readable).
215
+ * And of `tables`, the house's table names this credential sees: a table under the member's
216
+ * prefix that is neither one of its names nor one of a reached member's is a stray, whose owner
217
+ * cannot be told. The member takes the wildcard back only when it matches nothing else.
218
+ * collisions null (tables unlisted), or tables or users null (unread): that is unknown, and it
219
+ * is left. `users` is the server's users narrowed to those that may be members of this house
220
+ * (holdsMemberGrant), so one person's account in another house is not a collision here.
221
+ * revoke: the member issues the REVOKE; left: the reader may still hold it afterwards;
222
+ * colliding: every colliding name found; reaches: those whose tables it also matches, and
223
+ * unseen: those of them found by their user alone; strays: other tables it matches (null: unknown).
224
+ */
225
+ function wildcardOnRevoke({ member, collisions, heldWildcard, tables = null, users = null }) {
226
+ let colliding = null; let reaches = null; let strays = null;
227
+ if (collisions && tables && users) {
228
+ const near = (x) => x !== member && (x.startsWith(`${member}_`) || member.startsWith(`${x}_`));
229
+ colliding = [...new Set([...collisions, ...users.map(String).filter(near)])].sort();
230
+ reaches = colliding.filter((x) => x.startsWith(`${member}_`) || (member.startsWith(`${x}_`) && startsMemberTable(member.slice(x.length + 1))));
231
+ const holds = (m, t) => t.startsWith(`${m}_`) && isMemberTable(t.slice(m.length + 1));
232
+ strays = tables.map(String).filter((t) => t.startsWith(`${member}_`) && ![member, ...reaches].some((m) => holds(m, t))).sort();
233
+ }
234
+ const revoke = heldWildcard === true && !!reaches && !reaches.length && !strays.length;
235
+ // Found by its user alone: no table of theirs is in sight, so whether it is a member is unknown.
236
+ const unseen = reaches && reaches.filter((x) => !collisions.includes(x));
237
+ return { revoke, left: heldWildcard !== false && !revoke, colliding, reaches, unseen, strays };
238
+ }
239
+
240
+ /**
241
+ * May `user` be a member of house `db`, from its SHOW GRANTS lines? A member owns its tables: it
242
+ * holds them, `<db>.<user>_…` (or the archive's, `<db>_archive.<user>_…`), with ALL, INSERT, or a
243
+ * CREATE, DROP, ALTER, OPTIMIZE or TRUNCATE privilege, or with the right to grant them, none of
244
+ * which a share gives (a share is SELECT only). Any such word counts, so an admin's CREATE USER
245
+ * ON * does too: a false positive only leaves a grant for the admin. A broader target that covers them counts too (`*.*`, `<db>.*`, `<db>.b*`), since
246
+ * SHOW GRANTS lists only the broader line: an admin who also ships. So a reader of a colliding
247
+ * member, a stray SELECT on `<db>.<user>_*`, and an account in another house do not count. A user
248
+ * holding a role may hold a member's grant through it, so it counts.
249
+ */
250
+ function holdsMemberGrant(lines, db, user) {
251
+ if (rolesGranted(lines).length) return true;
252
+ const own = `${user}_`;
253
+ const covers = (t) => {
254
+ if (t === '*' || t === '*.*') return true;
255
+ const dot = t.indexOf('.');
256
+ if (dot < 0) return false;
257
+ const tdb = t.slice(0, dot); const tt = t.slice(dot + 1);
258
+ const dbs = [db, `${db}_archive`];
259
+ if (!(tdb.endsWith('*') ? dbs.some((d) => d.startsWith(tdb.slice(0, -1))) : dbs.includes(tdb))) return false;
260
+ if (!tt.endsWith('*')) return tt.startsWith(own);
261
+ const p = tt.slice(0, -1);
262
+ return own.startsWith(p) || p.startsWith(own);
263
+ };
264
+ return lines.some((line) => {
265
+ const { masked, names } = maskQuoted(line);
266
+ const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\b/.exec(masked);
267
+ if (!m || !covers(m[2].replace(/\u0000(\d+)\u0000/g, (_, i) => names[Number(i)]))) return false;
268
+ return /\bWITH GRANT OPTION\b/.test(masked) || /\b(ALL|INSERT|CREATE|DROP|ALTER|OPTIMIZE|TRUNCATE)\b/.test(m[1].replace(/\([^)]*\)/g, ''));
269
+ });
270
+ }
271
+
272
+ /**
273
+ * Of the server's `users`, those whose names collide with `member`'s and who may be members of
274
+ * house `db` (holdsMemberGrant). `read(name)` returns a user's SHOW GRANTS lines; one that cannot
275
+ * be read counts.
276
+ */
277
+ async function collidingUsers({ users, member, db, read }) {
278
+ const out = [];
279
+ for (const x of users.map(String)) {
280
+ if (x === member || !(x.startsWith(`${member}_`) || member.startsWith(`${x}_`))) continue;
281
+ let maybe = true;
282
+ try { maybe = holdsMemberGrant(await read(x), db, x); } catch { /* unread: counts */ }
283
+ if (maybe) out.push(x);
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /**
289
+ * Of the row policies in a member's database (system.row_policies, which can show other members'
290
+ * too, on tables this member cannot see), the ones on this member's own tables, by exact name.
291
+ * mine for the orphan check: any table memhouse names for the member (memberTargets: its
292
+ * 0.18 rooms, development and profile tables, meta, events, stats) and the share probe's
293
+ * scratch table (`<m>__mh_probe_<pid>`).
294
+ * grants for describing a grantee's scope: only the tables a share filters on this layout
295
+ * (schema 3: V3_TABLES and the development layout's; 0.18: its rooms), so a colliding
296
+ * name's table that spells a 0.18 room (bob_tool's calls is bob's tool_calls) is never
297
+ * read as this member's scope.
298
+ */
299
+ /**
300
+ * The statements that take SHOW ROW POLICIES back from a member, one per object its grants name
301
+ * it on (`SHOW GRANTS FOR <member>` lines). Members made before 0.20 hold it on <db>.<m>_*; it
302
+ * opens all of system.row_policies, every member's scoped shares included, and nothing memhouse
303
+ * does needs it. [] when the member holds none.
304
+ */
305
+ function showPoliciesRevokes(lines, member) {
306
+ const out = [];
307
+ for (const l of lines || []) {
308
+ const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\s+(\S+?)(?:\s+WITH\s+GRANT\s+OPTION)?\s*;?\s*$/i.exec(String(l).trim());
309
+ if (!m || m[3].replace(/`/g, '') !== member) continue;
310
+ if (!/(^|,\s*)SHOW\s+(ROW\s+)?POLICIES(\s*,|$)/i.test(m[1])) continue;
311
+ const sql = `REVOKE SHOW ROW POLICIES ON ${m[2]} FROM ${ident(member)}`;
312
+ if (!out.includes(sql)) out.push(sql);
313
+ }
314
+ return out;
315
+ }
316
+
317
+ function ownPolicies(policies, { db, member, schema3 }) {
318
+ const known = memberTargets({ db, archive: `${db}_archive`, member, legacy: null }).known;
319
+ const probe = new RegExp(`^${member}__mh_probe_\\d+$`);
320
+ const mine = policies.filter((p) => known.has(`${db}.${p.table}`) || probe.test(String(p.table)));
321
+ const shared = new Set((schema3 ? [...V3_TABLES, ...DEV_ROOMS] : LEGACY_ROOMS).map((t) => `${member}_${t}`));
322
+ return { mine, grants: mine.filter((p) => shared.has(String(p.table))) };
323
+ }
324
+
325
+ /** What else a left wildcard matches (wildcardOnRevoke), said in one clause. */
326
+ function wildcardReachText({ reaches, unseen = [], strays }, db) {
327
+ if (!reaches) return 'the house\'s tables or users could not be listed';
328
+ const parts = [];
329
+ const seen = reaches.filter((x) => !unseen.includes(x));
330
+ if (seen.length) parts.push(`the tables of ${seen.map((x) => `'${x}'`).join(', ')}`);
331
+ if (unseen.length) parts.push(`any table of ${unseen.map((x) => `'${x}'`).join(', ')} (a user whose name collides with yours and who may be a member here; no table of theirs is visible to you)`);
332
+ if (strays.length) parts.push(`${strays.map((t) => `${db}.${t}`).join(', ')}, which memhouse did not make for you`);
333
+ return `it also reaches ${parts.join(' and ')}`;
334
+ }
335
+
336
+ /**
337
+ * Who takes back a 0.18 wildcard the member could not, and how (`wc` from wildcardOnRevoke).
338
+ * Where member names collide, by hand: `migrate --v3-shares` refuses such a house. A REVOKE on the
339
+ * wildcard takes every table it matches, so strays are checked first. `--v3-shares` moves only a
340
+ * full share memhouse recorded as granted (classifyShare): `movable` says the record does (null:
341
+ * unread), and a revoke has just recorded it as revoked, so there it is by hand too.
342
+ */
343
+ function wildcardAdminLine({ wc, movable = false, db, member, user }) {
344
+ // movable: true, the record says --v3-shares would move it; false, it would not; null, unread;
345
+ // 'check', it would only if the reader does not hold the wildcard (a scoped record, beside
346
+ // grants that could not be read).
347
+ const guide = 'docs/guides/troubleshooting.md, "Two member names that collide"';
348
+ const wildcard = `${db}.${member}_*`;
349
+ if (wc.colliding && wc.colliding.length) return `The admin settles it by hand, because member names collide in this house (${guide}).`;
350
+ if (wc.strays && wc.strays.length) return `The admin settles it by hand: a REVOKE on ${wildcard} would also take the reader's grants on ${wc.strays.map((t) => `${db}.${t}`).join(', ')}, so the admin checks whose they are first.`;
351
+ if (movable === true) {
352
+ return wc.colliding ? 'The admin settles it: memhouse migrate --v3-shares, or SHOW GRANTS and REVOKE by hand.'
353
+ : `The admin settles it: memhouse migrate --v3-shares, or by hand if member names collide, since it refuses such a house (${guide}).`;
354
+ }
355
+ const by = `REVOKE SELECT ON ${wildcard} FROM ${ident(user)}`;
356
+ if (movable === 'check') return `The admin checks SHOW GRANTS FOR ${ident(user)} first: if it holds ${wildcard}, ${by} by hand (memhouse migrate --v3-shares refuses a scoped share beside it); if not, memhouse migrate --v3-shares moves the recorded share${wc.colliding ? '' : `, unless member names collide, since it refuses such a house (${guide})`}.`;
357
+ const why = `${movable === null ? 'the share record could not be read, and ' : ''}memhouse migrate --v3-shares moves only a full share memhouse recorded as granted`;
358
+ return wc.colliding ? `The admin settles it by hand: ${by} (${why}).`
359
+ : `The admin settles it by hand: ${by} (${why}), once the house's tables and users show that no member's name collides with yours and no table under ${db}.${member}_ is one memhouse did not make for you (${guide}).`;
360
+ }
361
+
362
+ // ── moving a 0.18 share onto schema 3 (memhouse migrate --v3-shares) ───────────────────────────
363
+
364
+ /**
365
+ * Every table of ONE member, by exact name: what a grant on this member can reach. Matching is by
366
+ * these names only, never by prefix: `mem.bob_` is also the start of `mem.bob_smith_turns`.
367
+ * `legacy` is the renamed rooms ({ messages, tool_calls, sessions } -> '<archive>.<m>_*_pre_v3').
368
+ */
369
+ function memberTargets({ db, archive, member, legacy }) {
370
+ const t = (x) => `${db}.${member}_${x}`;
371
+ const wildcard = `${db}.${member}_*`;
372
+ const before = { full: [wildcard], scoped: LEGACY_ROOMS.map(t) };
373
+ const after = V3_TABLES.map(t);
374
+ const known = new Set([wildcard, ...before.scoped, ...after, ...DEV_ROOMS.map(t),
375
+ ...['meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats'].map(t),
376
+ // The capture archive: the profile's two tables, and the development builds' two.
377
+ ...['session_units', 'session_sources', 'records', 'files'].map((x) => `${archive}.${member}_${x}`), ...Object.values(legacy || {})]);
378
+ return { wildcard, before, after, known };
379
+ }
380
+
381
+ /**
382
+ * What a user's SHOW GRANTS lines let them read of ONE member's tables (backticks dropped):
383
+ * an exact table of the member's, the member's wildcard, and anything broader that covers one of
384
+ * them — `*.*`, `<db>.*`, `<archive>.*`, or a prefix wildcard like `<db>.b*` that matches a table
385
+ * of the member's. `admin` is true for a server administrator (ALL, ACCESS MANAGEMENT or CREATE
386
+ * USER on *.*): they read everything by their own grants, which is no share.
387
+ */
388
+ function selectTargets(lines, known) {
389
+ const on = new Set(); let admin = false;
390
+ const tables = [...known].filter((k) => !k.endsWith('*'));
391
+ const covers = (target) => {
392
+ if (known.has(target)) return true;
393
+ if (target === '*.*' || target === '*') return tables.length > 0;
394
+ const dot = target.indexOf('.');
395
+ const tdb = target.slice(0, dot); const ttab = target.slice(dot + 1);
396
+ if (!ttab.endsWith('*')) return false;
397
+ const prefix = ttab.slice(0, -1);
398
+ return tables.some((k) => { const i = k.indexOf('.'); return k.slice(0, i) === tdb && k.slice(i + 1).startsWith(prefix); });
399
+ };
400
+ for (const line of lines) {
401
+ // Read with quoted names masked (maskQuoted), so a role or user named like grammar
402
+ // (`ALL ON *`) is never taken for a privilege; the target's names come back unquoted.
403
+ const { masked, names } = maskQuoted(line);
404
+ const m = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\b/.exec(masked);
405
+ if (!m) continue;
406
+ const target = m[2].replace(/\u0000(\d+)\u0000/g, (_, i) => names[Number(i)]).replace(/`/g, '');
407
+ // Global access rights print as `ON *` (CREATE USER, ROLE ADMIN) or `ON *.*` (ALL).
408
+ if ((target === '*' || target === '*.*') && /\bALL\b|ACCESS MANAGEMENT|CREATE USER|ROLE ADMIN/.test(m[1])) admin = true;
409
+ if (!/\bSELECT\b|\bALL\b/.test(m[1])) continue;
410
+ if (covers(target)) on.add(target);
411
+ }
412
+ return { on, admin };
413
+ }
414
+
415
+ /**
416
+ * One SHOW GRANTS line with each backtick-quoted name masked (\u0000<i>\u0000), so its structure
417
+ * (ON, TO, the commas) is read from what is outside the quotes only; the names come back
418
+ * unescaped, as ClickHouse escapes them (\\, \`, \n, \t, …).
419
+ */
420
+ function maskQuoted(line) {
421
+ const names = [];
422
+ const ESC = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', 0: '\0' };
423
+ const masked = String(line).replace(/`((?:[^`\\]|\\.)*)`/g, (_, body) => {
424
+ names.push(body.replace(/\\(.)/g, (m, c) => (c in ESC ? ESC[c] : c)));
425
+ return `\u0000${names.length - 1}\u0000`;
426
+ });
427
+ return { masked, names };
428
+ }
429
+
430
+ /**
431
+ * The roles one SHOW GRANTS listing grants (`GRANT r1, `r 2` TO u [WITH ADMIN OPTION]`), unquoted.
432
+ * A role's privileges are listed under the role, not under its grantee: a reader reaches whatever
433
+ * the roles granted to them reach, and the roles granted to those roles.
434
+ */
435
+ function rolesGranted(lines) {
436
+ const out = [];
437
+ for (const line of lines) {
438
+ const { masked, names } = maskQuoted(line);
439
+ if (/\sON\s/.test(masked)) continue;
440
+ const m = /^GRANT\s+(.+?)\s+TO\s+/.exec(masked);
441
+ if (!m) continue;
442
+ for (const part of m[1].split(',')) {
443
+ const p = part.trim();
444
+ const q = /^\u0000(\d+)\u0000$/.exec(p);
445
+ const name = q ? names[Number(q[1])] : p;
446
+ if (name) out.push(name);
447
+ }
448
+ }
449
+ return out;
450
+ }
451
+
452
+ /**
453
+ * Where one recorded share stands, from the grants ClickHouse holds (`on`, this member's targets
454
+ * only), the user's policies on the schema-3 tables (`policies`: a Set of table names), and
455
+ * whether an earlier run started moving it (`started`). Returns { apply: true } or { diff }.
456
+ * Only two states proceed without a started mark: exactly the 0.18 share memhouse records, or
457
+ * exactly the schema-3 share with the filters its scope needs. Anything else is a difference,
458
+ * and the caller changes nothing.
459
+ */
460
+ function classifyShare({ rec, on, policies, started, targets }) {
461
+ const set = (a) => [...a].sort().join(', ');
462
+ // A revoked record with grants still in place is a difference too, never a pass.
463
+ if (!rec.granted) return on.size ? { diff: `recorded as revoked, but holds ${set(on)}` } : { skip: true };
464
+ const same = (a, b) => a.length === b.size && a.every((x) => b.has(x));
465
+ const before = rec.scopeRaw ? targets.before.scoped : targets.before.full;
466
+ const allowed = new Set([...before, ...targets.after]);
467
+ const extra = [...on].filter((x) => !allowed.has(x));
468
+ if (extra.length) return { diff: `holds ${set(extra)} beyond what memhouse recorded (${rec.scopeRaw ? `scoped: ${rec.scopeRaw}` : 'full'})` };
469
+ if (!on.size) return { diff: 'recorded as granted, but holds nothing on these tables' };
470
+ // A scoped reader holding a schema-3 table must hold its filter: the move writes every filter
471
+ // before any grant, and a 0.18 scoped share's filter on <m>_sessions stays with that name.
472
+ const unfiltered = V3_TABLES.filter((t, i) => on.has(targets.after[i]) && !policies.has(t));
473
+ if (rec.scopeRaw && unfiltered.length) return { diff: `reads ${unfiltered.join(', ')} without the filter its scope (${rec.scopeRaw}) needs` };
474
+ if (started) {
475
+ // Only states the move itself passes through: scoped, any mix of the rooms and the filtered
476
+ // tables (checked above); full, the wildcard alone, or some of the tables without it. (A full
477
+ // move stopped between taking back the wildcard and the first grant holds nothing: that was
478
+ // refused above — settled by hand, never re-granted on a guess.)
479
+ if (!rec.scopeRaw && on.has(targets.wildcard) && on.size > 1) return { diff: `holds ${set(on)}: not a state the move passes through` };
480
+ return { apply: true, resume: true };
481
+ }
482
+ if (same(before, on)) return { apply: true };
483
+ if (same(targets.after, on)) {
484
+ const filtered = V3_TABLES.filter((t) => policies.has(t));
485
+ if (!rec.scopeRaw && filtered.length) return { diff: `recorded as a full share, but ${filtered.join(', ')} still carry a filter` };
486
+ return { apply: true };
487
+ }
488
+ return { diff: on.size ? `holds ${set(on)}, which is neither the recorded 0.18 share nor its schema-3 form` : 'recorded as granted, but holds nothing on these tables' };
489
+ }
490
+
491
+ /**
492
+ * The statements that move one recorded share onto schema 3: the schema-3 share itself
493
+ * (shareV3Plan: filters, then grants), then, once that is in place, the 0.18 grants it replaces —
494
+ * the rooms in <db> (never a name schema 3 uses too: <m>_sessions IS the new share) and the rooms
495
+ * renamed into the archive — and the 0.18 filters, each by exact name.
496
+ */
497
+ function moveSharePlan({ db, member, user, scope, legacy, holdsWildcard, sqlStr, parseSessionRef }) {
498
+ const NEW = V3_TABLES.map((t) => `${db}.${member}_${t}`);
499
+ const oldRooms = [...LEGACY_ROOMS.map((r) => `${db}.${member}_${r}`), ...Object.values(legacy || {})].filter((x) => !NEW.includes(x));
500
+ const share = shareV3Plan({ db, member, user, scope, sqlStr, parseSessionRef });
501
+ // A 0.18 full share's wildcard goes before the table grants: in ClickHouse a REVOKE on `m_*`
502
+ // subtracts every table it matches, the ones just granted included.
503
+ if (holdsWildcard) share.splice(share.findIndex((x) => x.phase === 'grant'), 0, wildcardRevoke({ db, member, user }));
504
+ return [
505
+ ...share,
506
+ ...oldRooms.map((fq) => ({ phase: 'clear', sql: `REVOKE SELECT ON ${qual(fq)} FROM ${ident(user)}` })),
507
+ ...legacyPolicyDrops({ db, member, user, legacy, keep: NEW }),
508
+ ];
509
+ }
510
+
511
+ /**
512
+ * What `--v3-shares` decides on, read from the house: the recorded shares, the runs that started
513
+ * and did not finish, who holds what on this member's tables, and the filters on its schema-3
514
+ * tables. `q.rows(sql)` runs one query. Any read that fails REJECTS, naming what could not be
515
+ * read: nothing unknown is ever taken for nothing.
516
+ */
517
+ async function readLiveShares(q, { db, member, meta, targets, sqlStr }) {
518
+ const M = qual(meta);
519
+ const read = async (what, sql) => {
520
+ try { return await q.rows(sql); } catch (e) { throw new Error(`could not read ${what}: ${String(e && e.message || e).split('\n')[0]}`); }
521
+ };
522
+ const recs = await read(meta, `SELECT substring(key, 7) AS user, argMax(value, updated_at) AS value FROM ${M}
523
+ WHERE key LIKE 'share:%' GROUP BY key ORDER BY key`);
524
+ // An earlier run that started moving a share and did not finish leaves 'started' here.
525
+ const marks = await read(meta, `SELECT substring(key, 9) AS user, argMax(value, updated_at) AS value FROM ${M}
526
+ WHERE key LIKE 'v3share:%' GROUP BY key`);
527
+ const users = (await read('system.users', 'SELECT name FROM system.users ORDER BY name')).map((r) => r.name).filter((u) => u !== member);
528
+ const live = new Map(); const admins = [];
529
+ // What a reader reaches through a role (and a role granted to that role), by role: memhouse
530
+ // never shares through a role, so any of it on this member is a grant it did not record.
531
+ const viaRole = new Map();
532
+ const roleLines = new Map();
533
+ const linesOfRole = async (r) => {
534
+ if (!roleLines.has(r)) roleLines.set(r, (await read(`the grants of role '${r}'`, `SHOW GRANTS FOR ${ident(r)}`)).map((x) => Object.values(x)[0]));
535
+ return roleLines.get(r);
536
+ };
537
+ for (const u of users) {
538
+ const text = (await read(`the grants of '${u}'`, `SHOW GRANTS FOR ${ident(u)}`)).map((r) => Object.values(r)[0]);
539
+ const { on, admin } = selectTargets(text, targets.known);
540
+ const { via, admin: roleAdmin } = await reachThroughRoles(text, targets.known, linesOfRole);
541
+ // An administrator reads everything by a global grant, which is no share: only that global
542
+ // grant is set aside, however it reached them (directly or through any role). Anything else
543
+ // they hold on this member counts as anyone's would.
544
+ if (admin || roleAdmin) {
545
+ admins.push(u);
546
+ for (const set of [on, ...via.map((v) => v.on)]) { set.delete('*.*'); set.delete('*'); }
547
+ }
548
+ if (on.size) live.set(u, on);
549
+ const kept = via.filter((v) => v.on.size);
550
+ if (kept.length) viaRole.set(u, kept);
551
+ }
552
+ const tables = (await read('the house\'s tables', `SELECT name FROM system.tables WHERE database = ${sqlStr(db)}`)).map((r) => r.name);
553
+ const pols = await read('system.row_policies', `SELECT short_name, table FROM system.row_policies
554
+ WHERE database = ${sqlStr(db)} AND table IN (${V3_TABLES.map((t) => sqlStr(`${member}_${t}`)).join(', ')})`);
555
+ return {
556
+ recorded: new Map(recs.map((r) => [r.user, parseShareRecord(r.value)])),
557
+ started: new Set(marks.filter((r) => /^started\b/.test(String(r.value))).map((r) => r.user)),
558
+ live, viaRole, admins, collisions: memberCollisions(member, tables),
559
+ policiesOf: (u) => new Set(V3_TABLES.filter((t) => pols.some((r) => r.short_name === policyName(u, t) && r.table === `${member}_${t}`))),
560
+ };
561
+ }
562
+
563
+ /**
564
+ * `memhouse members`: who holds what in the house and in its capture archive, directly or through a
565
+ * role (and a role granted to that role). `membersGrantsSQL` reads the grants of users and roles,
566
+ * MEMBERS_ROLE_GRANTS_SQL who holds which role, and `membersAccess` puts them together per user. A
567
+ * grant in the house is listed by table (`alice_*` for a wildcard), one in the archive by its full
568
+ * name (`mem_archive.alice_session_units`, `mem_archive.*`); one held through a role says so.
569
+ * `hasWild`: the server's system.grants has `is_wildcard` (26.x); before it, a wildcard grant's row
570
+ * carries only the bare prefix (`alice_`), so the trailing underscore is the marker.
571
+ */
572
+ function membersGrantsSQL({ db, archive, hasWild, sqlStr }) {
573
+ const star = hasWild ? "if(is_wildcard, '*', '')" : "if(endsWith(table, '_'), '*', '')";
574
+ return `SELECT user_name, role_name, database, table, ${star} AS star
575
+ FROM system.grants WHERE database IN (${sqlStr(db)}, ${sqlStr(archive)})`;
576
+ }
577
+ const MEMBERS_ROLE_GRANTS_SQL = 'SELECT user_name, role_name, granted_role_name FROM system.role_grants';
578
+
579
+ function membersAccess({ grants, roleGrants = [], db }) {
580
+ const label = (g) => (g.database === db ? (g.table == null ? null : `${g.table}${g.star || ''}`)
581
+ : `${g.database}.${g.table == null ? '*' : `${g.table}${g.star || ''}`}`);
582
+ const byUser = new Map(); const byRole = new Map();
583
+ const add = (m, k, g) => {
584
+ if (!m.has(k)) m.set(k, { dbWide: false, tables: new Set() });
585
+ const e = m.get(k);
586
+ if (g.database === db && g.table == null) e.dbWide = true;
587
+ const n = label(g); if (n) e.tables.add(n);
588
+ };
589
+ for (const g of grants) { if (g.user_name != null) add(byUser, g.user_name, g); else if (g.role_name != null) add(byRole, g.role_name, g); }
590
+ const rolesOf = new Map();
591
+ for (const r of roleGrants) {
592
+ const k = r.user_name != null ? `u:${r.user_name}` : `r:${r.role_name}`;
593
+ if (!rolesOf.has(k)) rolesOf.set(k, []);
594
+ rolesOf.get(k).push(r.granted_role_name);
595
+ }
596
+ const users = new Set([...byUser.keys(), ...roleGrants.filter((r) => r.user_name != null).map((r) => r.user_name)]);
597
+ const out = [];
598
+ for (const u of [...users].sort()) {
599
+ const own = byUser.get(u) || { dbWide: false, tables: new Set() };
600
+ let dbWide = own.dbWide; const tables = [...own.tables].sort();
601
+ const seen = new Set(); const queue = [...(rolesOf.get(`u:${u}`) || [])];
602
+ while (queue.length) {
603
+ const r = queue.shift();
604
+ if (seen.has(r)) continue;
605
+ seen.add(r);
606
+ const e = byRole.get(r);
607
+ if (e) {
608
+ if (e.dbWide) { dbWide = true; tables.push(`the whole database (through role ${r})`); }
609
+ for (const t of [...e.tables].sort()) tables.push(`${t} (through role ${r})`);
610
+ }
611
+ queue.push(...(rolesOf.get(`r:${r}`) || []));
612
+ }
613
+ if (dbWide || tables.length) out.push({ u, db_wide: dbWide ? 1 : 0, tables: tables.join(', '), list: tables });
614
+ }
615
+ return out;
616
+ }
617
+
618
+ /**
619
+ * What a user reaches of one member (`known`, memberTargets) through the roles their grant lines
620
+ * name, and the roles granted to those roles: [{ role, on }], cycles stopped. `linesOfRole(r)`
621
+ * returns a role's SHOW GRANTS lines, and rejects when they cannot be read. `admin`: a role holds
622
+ * an administrator's global grant (the caller sets that grant aside).
623
+ */
624
+ async function reachThroughRoles(grantLines, known, linesOfRole) {
625
+ const via = []; let admin = false;
626
+ const seen = new Set(); const queue = rolesGranted(grantLines);
627
+ while (queue.length) {
628
+ const r = queue.shift();
629
+ if (seen.has(r)) continue;
630
+ seen.add(r);
631
+ const rt = await linesOfRole(r);
632
+ const got = selectTargets(rt, known);
633
+ if (got.admin) admin = true;
634
+ if (got.on.size) via.push({ role: r, on: got.on });
635
+ queue.push(...rolesGranted(rt));
636
+ }
637
+ return { via, admin };
638
+ }
639
+
640
+ /**
641
+ * `memhouse share <user> --revoke`, after the REVOKE: what the reader still reads of the member
642
+ * through a role. A REVOKE from the reader leaves a role's privileges, so the revoke is not done
643
+ * while any remain; nor while they cannot be read (`unreadable`: why). Returns the lines to print
644
+ * and whether the revoke may say so.
645
+ */
646
+ function revokeRoleReport({ user, via, direct = [], collision = [], collisionOther = [], unlisted = [], unreadable = null }) {
647
+ if (unreadable) return { done: false, lines: [`'${user}' had their own grants withdrawn, but whether they still read your house through a role could not be checked: ${unreadable}. Ask the admin to check the roles '${user}' holds.`] };
648
+ const lines = [
649
+ ...(collision.length ? [`'${user}' still reaches your tables through the member grant ${collision.join(', ')}: your name and theirs collide (one is the other's plus an underscore), so no revoke closes it. Do not revoke that grant, which is their own house; see docs/guides/troubleshooting.md, "Two member names that collide"`] : []),
650
+ ...collisionOther.map(({ target, member }) => `'${user}' still reaches your tables through ${target}, member '${member}''s grant to them: your name and '${member}''s collide (one is the other's plus an underscore), so that grant reaches your tables too. It is '${member}''s grant, and in a house with colliding names an admin takes it back (docs/guides/troubleshooting.md, "Two member names that collide")`),
651
+ ...(unlisted.length ? [`'${user}' still holds ${unlisted.join(', ')}: the house's tables or users could not be listed, so whether that is another member's own house is unknown. An admin checks before revoking it`] : []),
652
+ ...(direct.length ? [`'${user}' still holds ${direct.join(', ')} by a grant memhouse did not make: an admin revokes it`] : []),
653
+ ...via.map((v) => `'${user}' can still read ${[...v.on].join(', ')} through role '${v.role}': revoke the role's grant, or remove '${user}' from '${v.role}' (an admin does either)`),
654
+ ];
655
+ return { done: !lines.length, lines };
656
+ }
657
+
658
+ /**
659
+ * The check a revoke ends with, once the reader's own grants are withdrawn: what they still read of
660
+ * the member (`known`, memberTargets). Their remaining direct grants (anything but the 0.18
661
+ * wildcard, which the caller handles), and what their roles reach, nested ones included. Nothing
662
+ * is set aside for an administrator: a REVOKE from the reader leaves a role's ALL ON *.* in place,
663
+ * and a direct administrator still reads everything, so either is reported. `read(name)` returns a
664
+ * user's or a role's SHOW GRANTS lines; a read that fails makes the check `unreadable`. `db` and
665
+ * `collisions` (wildcardOnRevoke's `colliding`: by table and by user; null when either could not be
666
+ * listed): a reader whose own member grant, `<db>.<their name>_*`, reaches
667
+ * this member's tables is a name collision, said as one, never "a grant memhouse did not make".
668
+ */
669
+ async function revokeCheck({ read, user, known, wildcard, db = null, collisions = [] }) {
670
+ try {
671
+ const lines = await read(user);
672
+ // The reader's own member grant (`<db>.<reader>_*`, their own house) and another colliding
673
+ // member's wildcard (that member's grant to them) reach this member only through the collision.
674
+ // collisions null: the house's tables or users could not be listed, so a member-shaped wildcard
675
+ // (`<db>.<name>_*`) may be another member's own house, and is never called a stray grant.
676
+ const cs = collisions || [];
677
+ const own = cs.includes(user) ? `${db}.${user}_*` : null;
678
+ const others = new Map(cs.filter((c) => c !== user).map((c) => [`${db}.${c}_*`, c]));
679
+ const held = [...selectTargets(lines, known).on].filter((t) => t !== wildcard);
680
+ const memberShaped = (t) => collisions === null && t.startsWith(`${db}.`) && /^[^.*]+_\*$/.test(t.slice(db.length + 1));
681
+ const { via } = await reachThroughRoles(lines, known, read);
682
+ return revokeRoleReport({ user, via: via.filter((v) => v.on.size),
683
+ unlisted: held.filter(memberShaped),
684
+ direct: held.filter((t) => t !== own && !others.has(t) && !memberShaped(t)),
685
+ collision: held.filter((t) => t === own),
686
+ collisionOther: held.filter((t) => others.has(t)).map((t) => ({ target: t, member: others.get(t) })) });
687
+ } catch (e) {
688
+ return revokeRoleReport({ user, via: [], unreadable: String(e && e.message || e).split('\n')[0] });
689
+ }
690
+ }
691
+
692
+ /** --v3-shares: each reader's role-held access to the member, as the difference that stops the move. */
693
+ function roleDifferences(viaRole) {
694
+ return [...viaRole].map(([u, via]) => `${u}: holds ${via.map((v) => `${[...v.on].join(', ')} through role '${v.role}'`).join('; ')} — memhouse never shares through a role; settle it by hand`);
695
+ }
696
+
697
+ /** The 0.18 filters of one user on one member, by exact name and table, wherever the rooms are now. */
698
+ function legacyPolicyDrops({ db, member, user, legacy, keep }) {
699
+ assertName(db, 'house'); assertName(member, 'member'); assertName(user, 'user');
700
+ const q2 = (fq) => fq.split('.').map(ident).join('.');
701
+ const out = [];
702
+ for (const r of LEGACY_ROOMS) {
703
+ const name = ident(policyName(user, r));
704
+ for (const fq of [`${db}.${member}_${r}`, legacy && legacy[r]].filter(Boolean)) {
705
+ if (keep && keep.includes(fq)) continue;
706
+ out.push({ phase: 'clear', sql: `DROP ROW POLICY IF EXISTS ${name} ON ${q2(fq)}` });
707
+ }
708
+ }
709
+ return out;
710
+ }
711
+
712
+ /** A meta share record's value -> { granted, scopeRaw } ('granted 2026-10-01 scope=project=x', 'revoked 2026-10-02'). */
713
+ function parseShareRecord(value) {
714
+ const v = String(value || '');
715
+ const m = /^granted\b[^]*?(?:\sscope=(.+))?$/.exec(v);
716
+ return m ? { granted: true, scopeRaw: m[1] ? m[1].trim() : null } : { granted: false, scopeRaw: null };
717
+ }
718
+
719
+ /**
720
+ * One account's grants from membersAccess, told apart for a reader: its own tables, the tables
721
+ * of each member it reads (a share), and anything else (a hand-made grant, a wildcard reaching
722
+ * past its own name). A table belongs to the member whose name is left after the longest
723
+ * memhouse table name (MEMBER_TABLES) is taken off its end. `policies` are the row policies the
724
+ * reading credential can list ([{ short_name, table }]); null when it cannot, and then a share's
725
+ * scope is unknown.
726
+ * @returns {{ own: string[], shares: {from:string, tables:string[], scoped:boolean|null}[], other: string[] }}
727
+ */
728
+ function describeAccess({ u, list }, { policies = null } = {}) {
729
+ const own = []; const other = []; const shared = new Map();
730
+ for (const label of list || []) {
731
+ const bare = String(label).replace(/ \(through role [^)]*\)$/, '');
732
+ const name = bare.includes('.') ? bare.slice(bare.lastIndexOf('.') + 1) : bare;
733
+ if (name === `${u}_*` || name === `${u}_`) { own.push(label); continue; }
734
+ const type = [...MEMBER_TABLES, 'session_units', 'session_sources'].filter((t) => name.endsWith(`_${t}`) && name.length > t.length + 1).sort((a, b) => b.length - a.length)[0];
735
+ const owner = type ? name.slice(0, -(type.length + 1)) : null;
736
+ if (owner === u) own.push(label);
737
+ else if (owner && !bare.includes('.') && V3_TABLES.concat(LEGACY_ROOMS).includes(type)) {
738
+ if (!shared.has(owner)) shared.set(owner, []);
739
+ shared.get(owner).push(label);
740
+ } else other.push(label);
741
+ }
742
+ const shares = [...shared.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([from, tables]) => ({
743
+ from, tables,
744
+ scoped: policies ? policies.some((p) => String(p.short_name).startsWith(`mh_share_${u}_`) && String(p.table).startsWith(`${from}_`)) : null,
745
+ }));
746
+ return { own, shares, other };
747
+ }
748
+
749
+ module.exports = { SCOPE_COLUMNS, parseScope, scopePredicate, policyName, V3_TABLES, scopePredicateV3, shareV3Plan, revokeV3Plan, parseShareRecord,
750
+ assertName, ident, qual, memberTargets, moveSharePlan, wildcardRevoke, memberCollisions, wildcardOnRevoke, holdsMemberGrant, collidingUsers, ownPolicies, showPoliciesRevokes, wildcardReachText, wildcardAdminLine, shareGate, selectTargets, rolesGranted, reachThroughRoles, revokeRoleReport, revokeCheck, classifyShare, legacyPolicyDrops, readLiveShares, membersGrantsSQL, MEMBERS_ROLE_GRANTS_SQL, membersAccess, describeAccess, roleDifferences, LEGACY_ROOMS };