memhouse 0.4.3 → 0.7.0
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.
- package/README.md +244 -114
- package/bin/memhouse.js +1169 -233
- package/editors/base.js +6 -0
- package/editors/claude.js +44 -9
- package/editors/goose.js +4 -0
- package/editors/index.js +0 -0
- package/editors/opencode.js +3 -0
- package/{mem-house → memhouse}/COMPETITION.md +5 -5
- package/{mem-house → memhouse}/DESIGN.md +18 -12
- package/memhouse/RESUME-AND-UPDATE.md +185 -0
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
- package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
- package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
- package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
- package/memhouse/host.js +101 -0
- package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
- package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
- package/memhouse/per-member/provision.js +265 -0
- package/memhouse/per-member/rooms.js +235 -0
- package/memhouse/per-member/schema-member.sql.tpl +125 -0
- package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
- package/memhouse/resume.js +150 -0
- package/memhouse/self-update.js +150 -0
- package/{mem-house → memhouse}/server/queries.js +80 -34
- package/memhouse/server/server.js +451 -0
- package/{mem-house → memhouse}/service.js +48 -29
- package/memhouse/shipper/ship.js +949 -0
- package/package.json +5 -20
- package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
- package/public/index.html +2 -2
- package/ui/index.html +1 -1
- package/ui/src/App.jsx +1 -1
- package/ui/src/lib/api.js +29 -13
- package/ui/src/pages/Dashboard.jsx +8 -5
- package/ui/src/pages/Sessions.jsx +3 -1
- package/ui/src/pages/SqlViewer.jsx +6 -23
- package/cache.js +0 -850
- package/ch-ingest.js +0 -329
- package/ch-schema.js +0 -152
- package/clickhouse.js +0 -87
- package/index.js +0 -425
- package/mcp-server.js +0 -294
- package/mem-house/delivery/install.sh +0 -183
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
- package/mem-house/per-member/provision.js +0 -142
- package/mem-house/per-member/rooms.js +0 -132
- package/mem-house/per-member/schema-member.sql.tpl +0 -85
- package/mem-house/server/server.js +0 -228
- package/mem-house/shipper/ship.js +0 -504
- package/relay-client.js +0 -306
- package/relay-server.js +0 -577
- package/server.js +0 -860
- package/share-image.js +0 -367
- /package/{mem-house → memhouse}/deploy.js +0 -0
- /package/{mem-house → memhouse}/envfile.js +0 -0
- /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
- /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
- /package/{mem-house → memhouse}/per-member/README.md +0 -0
- /package/{mem-house → memhouse}/per-member/SHARING.md +0 -0
|
@@ -50,7 +50,9 @@ Two properties that make the block safe to paste anywhere:
|
|
|
50
50
|
otherwise build Merge rooms pointing at `default` that return zero rows forever, with no
|
|
51
51
|
error at any point.
|
|
52
52
|
|
|
53
|
-
Measured:
|
|
53
|
+
Measured: 18 statements (the 17 objects plus a leading `SET
|
|
54
|
+
allow_experimental_full_text_index = 1`), run over HTTP with no database selected, 18
|
|
55
|
+
succeeded on both 26.7.2.59 and 25.11.9.34, and the
|
|
54
56
|
Merge rooms came out bound to `Merge('mem', '^sessions_')`.
|
|
55
57
|
|
|
56
58
|
## Option 2 — give the installer admin, it runs the SQL
|
|
@@ -78,31 +80,51 @@ who plays admin and how the handle is chosen.
|
|
|
78
80
|
## The grant set — two statements per room, and the split is the point
|
|
79
81
|
|
|
80
82
|
```sql
|
|
81
|
-
GRANT
|
|
83
|
+
GRANT SELECT, INSERT, ALTER UPDATE, ALTER DELETE, ALTER ADD COLUMN, OPTIMIZE
|
|
84
|
+
ON mem.sessions_polat TO polat;
|
|
82
85
|
GRANT SELECT ON mem.sessions_polat TO polat WITH GRANT OPTION;
|
|
83
86
|
```
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
before re-inserting, and which of `ALTER UPDATE` / `ALTER DELETE` a server demands varies
|
|
89
|
-
by version (`DELETE FROM` is a lightweight delete implemented as
|
|
90
|
-
`ALTER TABLE … UPDATE _row_exists = 0`, so 25.11 wants one where 26.7 wanted the other).
|
|
88
|
+
**Exactly what the shipper uses, not `ALL`.** `ALL` expands to 45 privileges on 26.7 and
|
|
89
|
+
one of them is `CREATE TABLE` **on the member's own room name** — so the member owns the
|
|
90
|
+
NAME, not just the data, and can do this:
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
```sql
|
|
93
|
+
DROP TABLE messages_polat;
|
|
94
|
+
CREATE TABLE messages_polat AS all_messages ENGINE = Merge('mem','^messages_');
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`all_messages` is `Merge(mem,'^messages_')`, so it then contains a Merge over its own
|
|
98
|
+
namespace and **every other member's rows are counted twice in the team room**. Measured:
|
|
99
|
+
a house reading alice 6030 / bob 2401 / carol 12 became bob 4802 / carol 24, with no error
|
|
100
|
+
on either ClickHouse version. Confidentiality survives — the Merge still narrows by grants,
|
|
101
|
+
so nothing new becomes readable — but a team-wide number is what that room is for.
|
|
102
|
+
|
|
103
|
+
`ALTER UPDATE` and `ALTER DELETE` are both named because `DELETE FROM` is a lightweight
|
|
104
|
+
delete implemented as `ALTER TABLE … UPDATE _row_exists = 0`, and which of the two a server
|
|
105
|
+
demands varies by version (25.11 wants one where 26.7 wanted the other). `ALTER ADD COLUMN`
|
|
106
|
+
is for `ensureSchema`'s rollout. Replacing a room is the admin's job, not the member's.
|
|
96
107
|
|
|
97
|
-
|
|
108
|
+
**`SELECT`, re-grantable.** A share is read-only **by construction**, not by convention:
|
|
109
|
+
the member can hand a colleague `SELECT` and nothing else, because they hold nothing else
|
|
110
|
+
to hand over.
|
|
111
|
+
|
|
112
|
+
Measured on 26.7.2 and 25.11.9:
|
|
98
113
|
|
|
99
114
|
```
|
|
100
115
|
polat grants SELECT to bob ok
|
|
101
116
|
polat grants DROP TABLE to bob 497 Not enough privileges
|
|
102
|
-
polat drops their own room
|
|
117
|
+
polat drops their own room 497 Not enough privileges
|
|
118
|
+
polat creates any table 497 Not enough privileges
|
|
119
|
+
polat installs, ships, resets ok
|
|
103
120
|
bob ends up with GRANT SELECT ON mem.sessions_polat
|
|
104
121
|
```
|
|
105
122
|
|
|
123
|
+
One definition, in `rooms.js` as `MEMBER_PRIVS`, used by both `provision.js` and
|
|
124
|
+
`install --print-sql`. They drifted once — the first was narrowed and the second went on
|
|
125
|
+
emitting `GRANT ALL` for a release, so the attack above stayed reachable through the path
|
|
126
|
+
this document calls the common case.
|
|
127
|
+
|
|
106
128
|
Never `ON mem.*`. A member who can read `mem.*` can read every other member's rooms, and
|
|
107
129
|
then this is a shared house with longer table names.
|
|
108
130
|
|
|
@@ -181,3 +203,26 @@ failed install leaves a config behind that the next command reads as truth.
|
|
|
181
203
|
`memhouse service install` inlines the same values into the unit file, because a service
|
|
182
204
|
must not depend on a shell-quoted file it does not parse the same way. That is a second
|
|
183
205
|
copy, also 0600, removed by `memhouse service uninstall`.
|
|
206
|
+
|
|
207
|
+
## `--adopt-user` needs house admin — it is not the new-machine path
|
|
208
|
+
|
|
209
|
+
`--adopt-user` is read only on the `--admin-user` branch, so it provisions a member and
|
|
210
|
+
requires admin on the house. Passing it without `--admin-user` used to be ignored silently;
|
|
211
|
+
it is refused now.
|
|
212
|
+
|
|
213
|
+
The actual "same person, new machine" path needs no admin at all — you already have a
|
|
214
|
+
member credential, so just use it:
|
|
215
|
+
|
|
216
|
+
memhouse install --url https://… --user <member> --password …
|
|
217
|
+
|
|
218
|
+
That finds the existing rooms and writes the config. `--adopt-user` is for the case where
|
|
219
|
+
an admin is *creating or re-attaching* a member and wants the existing password verified
|
|
220
|
+
before anything is written.
|
|
221
|
+
|
|
222
|
+
## Offboarding, from the member's side
|
|
223
|
+
|
|
224
|
+
`DROP USER <member>` leaves their rooms and revokes everything (verified: no orphan grants
|
|
225
|
+
survive). From that member's machine every command then fails with `AUTHENTICATION_FAILED`
|
|
226
|
+
and advises reinstalling — which cannot work, and is indistinguishable from a wrong
|
|
227
|
+
password. There is no signal that says "you were offboarded"; ClickHouse does not
|
|
228
|
+
distinguish the two either. Worth knowing before you use it on someone.
|
|
@@ -71,3 +71,50 @@ which is what it was always supposed to mean.
|
|
|
71
71
|
|
|
72
72
|
- the kernel capability that provisions a member
|
|
73
73
|
- member removal: drop the three rooms, revoke, decide what happens to rooms shared outward
|
|
74
|
+
|
|
75
|
+
## What a member can still learn about the others
|
|
76
|
+
|
|
77
|
+
Isolation is a grant that is absent, so no member can read another's rows — verified by
|
|
78
|
+
trying every read and write against another member's rooms, plus `system.parts`,
|
|
79
|
+
`system.query_log`, `system.processes` and the rest, all of which refuse. Two things do
|
|
80
|
+
leak, both structural rather than bugs in the code, and neither is fixed by tightening a
|
|
81
|
+
grant:
|
|
82
|
+
|
|
83
|
+
**The roster.** A member's handle is part of their table name, and ClickHouse distinguishes
|
|
84
|
+
"forbidden" from "absent": `SELECT count() FROM messages_bob` returns `Code: 497
|
|
85
|
+
ACCESS_DENIED` (HTTP 500) if bob exists and `Code: 60 UNKNOWN_TABLE` (HTTP 404) if he does
|
|
86
|
+
not. So any member can enumerate the membership of the house by guessing handles.
|
|
87
|
+
`system.tables` correctly hides the other rooms; this reopens it. `EXISTS TABLE` does not
|
|
88
|
+
leak — it answers 497 either way — so it is specifically the natural probe that talks. If
|
|
89
|
+
the membership of a house is itself sensitive, the per-member layout is the wrong shape for
|
|
90
|
+
it.
|
|
91
|
+
|
|
92
|
+
**Volume.** `system.tables.total_rows` and `total_bytes` are readable for the Merge rooms,
|
|
93
|
+
and a Merge room spans every member:
|
|
94
|
+
|
|
95
|
+
name total_rows total_bytes
|
|
96
|
+
all_messages 87 42455 <- the whole house
|
|
97
|
+
messages_alice 33 25070 <- what alice can actually read
|
|
98
|
+
|
|
99
|
+
So a member can see the house's combined row count and byte volume, and by polling it, when
|
|
100
|
+
other members are shipping. Withholding `SELECT` on the Merge rooms is not the fix — it
|
|
101
|
+
makes the team room deny rather than narrow, which is the property those rooms exist for.
|
|
102
|
+
|
|
103
|
+
## Why members are not granted ALL on their own rooms
|
|
104
|
+
|
|
105
|
+
`GRANT ALL ON <db>.messages_<m>` expands to 45 privileges on 26.7, and one of them is
|
|
106
|
+
`CREATE TABLE` **on that name**. A member would then own the name rather than the data:
|
|
107
|
+
|
|
108
|
+
DROP TABLE messages_alice;
|
|
109
|
+
CREATE TABLE messages_alice AS all_messages ENGINE = Merge('<db>','^messages_');
|
|
110
|
+
|
|
111
|
+
`all_messages` is `Merge(db,'^messages_')`, so it now contains a Merge over its own
|
|
112
|
+
namespace and every other member's rows are counted twice in the team room — measured, a
|
|
113
|
+
house reading alice 6030 / bob 2401 / carol 12 became bob 4802 / carol 24, with no error.
|
|
114
|
+
Confidentiality survives (the Merge still narrows by grants) but integrity does not, and a
|
|
115
|
+
team-wide number is what the room is for. It is the same failure recorded above under the
|
|
116
|
+
`sessions_v` rename, reached on purpose instead of by accident.
|
|
117
|
+
|
|
118
|
+
Members therefore get exactly what the shipper uses — `SELECT, INSERT, ALTER UPDATE, ALTER
|
|
119
|
+
DELETE, ALTER ADD COLUMN, OPTIMIZE` — and nothing that defines an object. Replacing a room
|
|
120
|
+
is the admin's job.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mem — provision one member's rooms and grants. Idempotent; also the schema-rollout hook.
|
|
3
|
+
//
|
|
4
|
+
// provision.js --member <name> [--merge] [--owner-grants]
|
|
5
|
+
//
|
|
6
|
+
// Run as the OWNER. Steps, all IF NOT EXISTS / re-runnable:
|
|
7
|
+
// 1. create the member's three rooms from schema-member.sql.tpl
|
|
8
|
+
// 2. grant the member their own three rooms, TWO statements each: MEMBER_PRIVS (not
|
|
9
|
+
// re-grantable), then `SELECT WITH GRANT OPTION`. The split is the point —
|
|
10
|
+
// MEMBER_PRIVS is exactly what the shipper uses and nothing that DEFINES an object,
|
|
11
|
+
// and attaching grant-option to `SELECT` alone makes a share read-only by
|
|
12
|
+
// construction. It is deliberately NOT `ALL`: that includes CREATE TABLE on the
|
|
13
|
+
// member's own room name, which lets them replace the room with a Merge over every
|
|
14
|
+
// member's and double the others' rows in the team room. See rooms.js.
|
|
15
|
+
//
|
|
16
|
+
// THREE ROOMS, not four objects: the session rollup is a saved query over these
|
|
17
|
+
// same rooms, not a stored view, so it needs no object and no grant of its own.
|
|
18
|
+
// 3. --merge: create/refresh the three Merge rooms, borrowing this member's columns
|
|
19
|
+
// 4. grant the member SELECT on whichever Merge rooms exist — a Merge reduces to the
|
|
20
|
+
// rooms the caller can already read, so this is what makes the team room fail
|
|
21
|
+
// CLOSED rather than deny outright
|
|
22
|
+
//
|
|
23
|
+
// Grants are explicit, per room, and NEVER `ON mem.*` — a member who can read the whole
|
|
24
|
+
// house can read every other member's rooms, which would make this a shared house with
|
|
25
|
+
// longer table names. No wildcards are used anywhere; the only pattern in the design is
|
|
26
|
+
// the Merge regex, anchored on a fixed room type.
|
|
27
|
+
//
|
|
28
|
+
// Env: MEM_URL / MEM_USER / MEM_PASSWORD / MEM_DB, falling back to MEMHOUSE_*. URL and
|
|
29
|
+
// USER are REQUIRED — see the note at cfg. MEM_DB defaults to 'mem'.
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
// Named, not thrown as a raw MODULE_NOT_FOUND with a require stack. This runs as a
|
|
35
|
+
// SUBPROCESS of `memhouse install`, after the admin path has already created a ClickHouse
|
|
36
|
+
// user — so its failure mode is a half-provisioned house, and "Cannot find module" plus a
|
|
37
|
+
// stack trace is the least useful thing to print at that moment. Seen for real in a
|
|
38
|
+
// checkout where `npm install` had not been run.
|
|
39
|
+
let createClient, ClickHouseLogLevel;
|
|
40
|
+
try { ({ createClient, ClickHouseLogLevel } = require('@clickhouse/client')); }
|
|
41
|
+
catch {
|
|
42
|
+
console.error("provision.js: dependency '@clickhouse/client' is not installed.");
|
|
43
|
+
console.error(` From a checkout: npm install --prefix ${path.join(__dirname, '..', '..')}`);
|
|
44
|
+
console.error(` From an npm install: ${require('./rooms').installCommand()}`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
const { ROOM_TYPES, mergeRooms, assertUsableMember, MEMBER_PRIVS, MEMBER_PROFILE, MEMBER_PROFILE_SETTINGS } = require('./rooms');
|
|
48
|
+
|
|
49
|
+
const args = process.argv.slice(2);
|
|
50
|
+
const flag = (n) => args.includes(`--${n}`);
|
|
51
|
+
const opt = (n, d = null) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : d; };
|
|
52
|
+
|
|
53
|
+
// Defaults MUST match what the shipper and CLI use, or an operator who overrides only
|
|
54
|
+
// the thing that differs — a password, say — silently provisions rooms in a house the
|
|
55
|
+
// product never reads, or fails to authenticate as a user that does not exist.
|
|
56
|
+
//
|
|
57
|
+
// Which is why there is no default URL or user any more, here either. This file CREATES
|
|
58
|
+
// USERS AND GRANTS; pointing it at a guessed http://localhost:8123 as memhouse_root aims
|
|
59
|
+
// the most privileged operation in the product at whatever house happens to be listening.
|
|
60
|
+
// Observed: running it with no env at all made an authentication attempt against the
|
|
61
|
+
// pilot's own ClickHouse.
|
|
62
|
+
const cfg = {
|
|
63
|
+
url: process.env.MEM_URL || process.env.MEMHOUSE_URL,
|
|
64
|
+
username: process.env.MEM_USER || process.env.MEMHOUSE_USER,
|
|
65
|
+
password: process.env.MEM_PASSWORD || process.env.MEMHOUSE_PASSWORD || '',
|
|
66
|
+
database: process.env.MEM_DB || process.env.MEMHOUSE_DB || 'mem',
|
|
67
|
+
};
|
|
68
|
+
if (!cfg.url || !cfg.username) {
|
|
69
|
+
console.error('provision.js: no house given. Set MEM_URL and MEM_USER (or MEMHOUSE_URL/MEMHOUSE_USER).');
|
|
70
|
+
console.error(' This creates users and grants — it will not guess http://localhost:8123 as memhouse_root.');
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Statements are split on ';' at end of line — the templates contain no ';' inside a
|
|
75
|
+
// statement body, and keeping the splitter dumb keeps the templates readable.
|
|
76
|
+
function statements(sql) {
|
|
77
|
+
return sql
|
|
78
|
+
.split(/;\s*$/m)
|
|
79
|
+
.map((s) => s.replace(/^\s*--.*$/gm, '').trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Which of the three Merge rooms actually exist — a member may be provisioned first. */
|
|
84
|
+
async function mergeRoomsPresent(client, database) {
|
|
85
|
+
const names = Object.values(mergeRooms());
|
|
86
|
+
const rs = await client.query({
|
|
87
|
+
query: `SELECT name FROM system.tables WHERE database = {db:String} AND name IN ({names:Array(String)}) ORDER BY name`,
|
|
88
|
+
query_params: { db: database, names },
|
|
89
|
+
format: 'JSONEachRow',
|
|
90
|
+
});
|
|
91
|
+
return (await rs.json()).map((r) => r.name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main() {
|
|
95
|
+
const member = opt('member');
|
|
96
|
+
if (!member) { console.error('usage: provision.js --member <name> [--merge]'); process.exit(2); }
|
|
97
|
+
assertUsableMember(member);
|
|
98
|
+
|
|
99
|
+
const here = __dirname;
|
|
100
|
+
|
|
101
|
+
// Create the house with a client that has NOT selected it — on a fresh server the
|
|
102
|
+
// database does not exist yet, and selecting it fails before it can be created.
|
|
103
|
+
// See the note in shipper/ship.js: the driver's ERROR-level dump buries every
|
|
104
|
+
// refusal this file prints. MEMHOUSE_DEBUG=1 restores it.
|
|
105
|
+
const quiet = { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF };
|
|
106
|
+
const bootstrap = createClient({ ...cfg, database: '', log: quiet });
|
|
107
|
+
await bootstrap.command({ query: `CREATE DATABASE IF NOT EXISTS ${cfg.database}` });
|
|
108
|
+
await bootstrap.close();
|
|
109
|
+
|
|
110
|
+
console.log(`[mem] provisioning in ${cfg.url} database '${cfg.database}' as '${cfg.username}'`);
|
|
111
|
+
const client = createClient({ ...cfg, log: quiet, clickhouse_settings: { async_insert: 0 } });
|
|
112
|
+
|
|
113
|
+
// 0. the member themselves.
|
|
114
|
+
//
|
|
115
|
+
// This file never created the ClickHouse user, so the path `memhouse install` prints and
|
|
116
|
+
// INSTALL.md repeats — `provision.js --member <name> --merge` — created three rooms and
|
|
117
|
+
// then died on `There is no role \`<name>\` in \`user directories\``, leaving orphan rooms
|
|
118
|
+
// that nothing removes and that the --merge step then folded into all_sessions. It also
|
|
119
|
+
// ignored --member-password. The documented second-member path did not work at all.
|
|
120
|
+
const existing = await client.query({
|
|
121
|
+
query: `SELECT name FROM system.users WHERE name = {n:String}`,
|
|
122
|
+
query_params: { n: member }, format: 'JSONEachRow',
|
|
123
|
+
});
|
|
124
|
+
if ((await existing.json()).length === 0) {
|
|
125
|
+
const pw = opt('member-password') || crypto.randomBytes(24).toString('base64url');
|
|
126
|
+
await client.command({ query: `CREATE USER ${member} IDENTIFIED BY '${pw.replace(/'/g, "\\'")}'` });
|
|
127
|
+
console.log(`[mem] created ClickHouse user '${member}'`);
|
|
128
|
+
if (!opt('member-password')) {
|
|
129
|
+
console.log(`[mem] password for '${member}': ${pw}`);
|
|
130
|
+
console.log('[mem] shown once — hand it over, or pass --member-password next time');
|
|
131
|
+
}
|
|
132
|
+
console.log(`[mem] they finish with: memhouse install --url ${cfg.url} --db ${cfg.database} --user ${member} --password '…'`);
|
|
133
|
+
} else {
|
|
134
|
+
console.log(`[mem] ClickHouse user '${member}' already exists — provisioning rooms only`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 1. rooms
|
|
138
|
+
const tpl = fs.readFileSync(path.join(here, 'schema-member.sql.tpl'), 'utf-8');
|
|
139
|
+
const roomSql = statements(tpl.replaceAll('{{MEMBER}}', member));
|
|
140
|
+
for (const q of roomSql) {
|
|
141
|
+
// allow_experimental_full_text_index: a no-op on 26.x, required on 25.x for the
|
|
142
|
+
// messages text indexes.
|
|
143
|
+
await client.command({ query: q, clickhouse_settings: { allow_experimental_full_text_index: 1 } });
|
|
144
|
+
}
|
|
145
|
+
// CREATE TABLE IF NOT EXISTS is a no-op on an existing room, so re-running provision.js
|
|
146
|
+
// over a room that has DRIFTED left it lossy while printing "rooms ready". The owner's
|
|
147
|
+
// route has to heal as well as create — it is the one route that always holds the rights.
|
|
148
|
+
{
|
|
149
|
+
const { templateColumns } = require('../shipper/ship');
|
|
150
|
+
const want = templateColumns(tpl);
|
|
151
|
+
for (const ty of ROOM_TYPES) {
|
|
152
|
+
const room = `${ty}_${member}`;
|
|
153
|
+
const rs = await client.query({
|
|
154
|
+
query: 'SELECT name FROM system.columns WHERE database = {d:String} AND table = {n:String}',
|
|
155
|
+
query_params: { d: cfg.database, n: room }, format: 'JSONEachRow',
|
|
156
|
+
});
|
|
157
|
+
const have = new Set((await rs.json()).map((r) => r.name));
|
|
158
|
+
if (!have.size) continue;
|
|
159
|
+
for (const c of (want[ty] || [])) {
|
|
160
|
+
if (have.has(c.name)) continue;
|
|
161
|
+
await client.command({
|
|
162
|
+
query: `ALTER TABLE ${cfg.database}.${room} ADD COLUMN IF NOT EXISTS ${c.name} ${c.type}`,
|
|
163
|
+
clickhouse_settings: { allow_experimental_full_text_index: 1 },
|
|
164
|
+
});
|
|
165
|
+
console.log(`[mem] added missing column ${room}.${c.name}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
console.log(`[mem] rooms ready for '${member}': ${ROOM_TYPES.map((t) => `${t}_${member}`).join(', ')}`);
|
|
170
|
+
|
|
171
|
+
// 2. grants — two statements per room, and the split is the point.
|
|
172
|
+
//
|
|
173
|
+
// ALL, NOT re-grantable: the member owns their room outright, DROP and TRUNCATE
|
|
174
|
+
// included, because it is their memory to destroy. It also covers the mutation
|
|
175
|
+
// privileges the shipper needs without naming them — the shipper clears a session's
|
|
176
|
+
// rows before re-inserting, and which of ALTER UPDATE / ALTER DELETE a server demands
|
|
177
|
+
// varies by version (`DELETE FROM` is a lightweight delete, implemented as
|
|
178
|
+
// `ALTER TABLE … UPDATE _row_exists = 0`, so 25.11 wants ALTER UPDATE where 26.7 wanted
|
|
179
|
+
// ALTER DELETE). Granting one and not the other produces a pass that fails elsewhere.
|
|
180
|
+
//
|
|
181
|
+
// SELECT, re-grantable: a share is READ-ONLY BY CONSTRUCTION rather than by convention.
|
|
182
|
+
// `GRANT ALL … WITH GRANT OPTION` expands to 45 privileges on 26.7 — DROP TABLE,
|
|
183
|
+
// TRUNCATE, CREATE ROW POLICY, SYSTEM DROP REPLICA among them — and every one of those
|
|
184
|
+
// would become something a member could hand to a colleague while meaning "let them
|
|
185
|
+
// read my sessions". Measured: with this split, `GRANT SELECT … TO bob` succeeds and
|
|
186
|
+
// `GRANT DROP TABLE … TO bob` is refused 497.
|
|
187
|
+
// NOT `GRANT ALL`. On 26.7 that expands to 45 privileges including CREATE TABLE **on
|
|
188
|
+
// the member's own room name** — which means a member owns the NAME, not just the data,
|
|
189
|
+
// and can put any engine behind it. Measured on both versions:
|
|
190
|
+
//
|
|
191
|
+
// DROP TABLE messages_alice; -- allowed
|
|
192
|
+
// CREATE TABLE messages_alice AS all_messages
|
|
193
|
+
// ENGINE = Merge('<db>','^messages_'); -- allowed
|
|
194
|
+
//
|
|
195
|
+
// `all_messages` is Merge(db,'^messages_'), so it then contains a Merge over its own
|
|
196
|
+
// namespace and every OTHER member's rows are counted twice in the team room: a house
|
|
197
|
+
// reading alice 6030 / bob 2401 / carol 12 became bob 4802 / carol 24, silently, no
|
|
198
|
+
// error. Confidentiality survives — the Merge still narrows by grants, so alice reads
|
|
199
|
+
// nothing new — but integrity does not, and a team-wide number is the whole point of
|
|
200
|
+
// the room. SCHEMA.md already records the same failure reached by accident; this is the
|
|
201
|
+
// same failure reachable on purpose by any member.
|
|
202
|
+
//
|
|
203
|
+
// So: the privileges the shipper actually uses, and nothing that lets a member define
|
|
204
|
+
// an object. ALTER UPDATE and ALTER DELETE both, because `DELETE FROM` is a lightweight
|
|
205
|
+
// delete implemented as `ALTER TABLE … UPDATE _row_exists = 0` and which of the two a
|
|
206
|
+
// server demands varies by version (25.11 wants UPDATE where 26.7 wanted DELETE).
|
|
207
|
+
// ALTER ADD COLUMN for ensureSchema's rollout. No CREATE TABLE, no DROP TABLE, no
|
|
208
|
+
// TRUNCATE: the admin who provisioned the room is the one who can replace it.
|
|
209
|
+
//
|
|
210
|
+
// SELECT is re-grantable so a share is READ-ONLY BY CONSTRUCTION rather than by
|
|
211
|
+
// convention — `GRANT SELECT … TO bob` succeeds, `GRANT DROP TABLE … TO bob` is 497.
|
|
212
|
+
for (const t of ROOM_TYPES) {
|
|
213
|
+
const room = `${cfg.database}.${t}_${member}`;
|
|
214
|
+
await client.command({ query: `GRANT ${MEMBER_PRIVS} ON ${room} TO ${member}` });
|
|
215
|
+
await client.command({ query: `GRANT SELECT ON ${room} TO ${member} WITH GRANT OPTION` });
|
|
216
|
+
}
|
|
217
|
+
console.log(`[mem] granted ${MEMBER_PRIVS} on 3 rooms to '${member}'; SELECT is the only re-grantable one`);
|
|
218
|
+
|
|
219
|
+
// 2b. A settings profile with CEILINGS, not just defaults.
|
|
220
|
+
//
|
|
221
|
+
// Every isolation test passed on confidentiality and none existed for availability: a
|
|
222
|
+
// member could `SETTINGS max_memory_usage=100000000000`, `max_execution_time=0` and
|
|
223
|
+
// `max_threads=64` on a shared house, and take the server down for everyone. The
|
|
224
|
+
// constraint form (`MAX`) is what makes it a ceiling — a plain default is advisory and a
|
|
225
|
+
// member simply overrides it, which is what they were doing.
|
|
226
|
+
//
|
|
227
|
+
// Generous on purpose: a full re-ship of a large house is a big INSERT, and the point is
|
|
228
|
+
// to stop one member exhausting the box, not to make honest work fail.
|
|
229
|
+
try {
|
|
230
|
+
await client.command({ query: `CREATE SETTINGS PROFILE OR REPLACE ${MEMBER_PROFILE} SETTINGS ${MEMBER_PROFILE_SETTINGS}` });
|
|
231
|
+
await client.command({ query: `ALTER USER ${member} SETTINGS PROFILE '${MEMBER_PROFILE}'` });
|
|
232
|
+
console.log(`[mem] settings profile '${MEMBER_PROFILE}' applied to '${member}' (memory, time and thread ceilings)`);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
// A server where the admin cannot create profiles still gets a working member; say so
|
|
235
|
+
// rather than failing the provision.
|
|
236
|
+
console.log(`[mem] note: could not apply the '${MEMBER_PROFILE}' settings profile — ${e.message}`);
|
|
237
|
+
console.log('[mem] the member works, but nothing bounds their query resources on this server');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 3. Merge rooms
|
|
241
|
+
if (flag('merge')) {
|
|
242
|
+
const mtpl = fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8');
|
|
243
|
+
for (const q of statements(mtpl.replaceAll('{{TEMPLATE_MEMBER}}', member))) {
|
|
244
|
+
await client.command({ query: q });
|
|
245
|
+
}
|
|
246
|
+
console.log('[mem] merge rooms ready: all_sessions, all_messages, all_tool_calls');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 4. Merge-room grants. Broad on purpose, and safe for the reason the whole layout
|
|
250
|
+
// rests on: a Merge table reduces to the underlying rooms the CALLER holds grants for.
|
|
251
|
+
// Measured on 25.11 — bob with SELECT on all_messages and nothing on messages_alice
|
|
252
|
+
// reads his own row and none of alice's, with `_table` showing only messages_bob, and
|
|
253
|
+
// no error. Withholding this grant does not make anything safer; it makes the team
|
|
254
|
+
// room deny outright instead of failing closed, which is a different and worse thing.
|
|
255
|
+
const merged = await mergeRoomsPresent(client, cfg.database);
|
|
256
|
+
for (const room of merged) {
|
|
257
|
+
await client.command({ query: `GRANT SELECT ON ${cfg.database}.${room} TO ${member}` });
|
|
258
|
+
}
|
|
259
|
+
if (merged.length) console.log(`[mem] granted SELECT on ${merged.join(', ')} to '${member}'`);
|
|
260
|
+
else console.log('[mem] no merge rooms yet — run once with --merge to create them');
|
|
261
|
+
|
|
262
|
+
await client.close();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
main().catch((e) => { console.error(`[mem] provision failed: ${e.message}`); process.exit(1); });
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Room-name resolution.
|
|
2
|
+
//
|
|
3
|
+
// Every member owns their rooms, named TYPE-FIRST: `sessions_<member>`,
|
|
4
|
+
// `messages_<member>`, `tool_calls_<member>`. Type-first is what lets the Merge rooms
|
|
5
|
+
// anchor on a fixed room type (`^sessions_`) and never match themselves.
|
|
6
|
+
//
|
|
7
|
+
// THIS IS THE ONLY LAYOUT. There was a shared one — three rooms named `sessions`,
|
|
8
|
+
// `messages`, `tool_calls`, with row policies narrowing each caller to their own rows —
|
|
9
|
+
// and it is gone. A shared read is a Merge room plus a GRANT, which is strictly less
|
|
10
|
+
// machinery than a policy that has to be right on every table and every read path; and
|
|
11
|
+
// isolation by absent grant fails closed, where a row policy fails open the moment one is
|
|
12
|
+
// missing.
|
|
13
|
+
//
|
|
14
|
+
// The member is never supplied by the caller — it is read back from the server with
|
|
15
|
+
// currentUser(), the same value the rooms' user_id column is stamped with. A client that
|
|
16
|
+
// could name its own member could write into someone else's rooms.
|
|
17
|
+
//
|
|
18
|
+
// `sessions_v` IS A SAVED QUERY, NOT A ROOM. It was briefly a stored view, which cost a
|
|
19
|
+
// name inside the `^sessions_` namespace the Merge rooms select on (silently merged into
|
|
20
|
+
// `all_sessions`, doubling every count), a fourth grant per member, and a fourth object to
|
|
21
|
+
// provision, roll forward and drop. A saved query is substituted with the caller's own
|
|
22
|
+
// room names and runs under the caller's credential, so it inherits their grants and
|
|
23
|
+
// raises no view-ownership question at all.
|
|
24
|
+
|
|
25
|
+
const ROOM_TYPES = ['sessions', 'messages', 'tool_calls'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a member is granted on their own rooms. ONE definition, because there are two paths
|
|
29
|
+
* that provision a house — provision.js and `install --print-sql` — and when only the first
|
|
30
|
+
* was narrowed the second went on emitting `GRANT ALL` for a release. GRANT ALL includes
|
|
31
|
+
* CREATE TABLE **on the member's own room name**, so a member owns the name rather than the
|
|
32
|
+
* data and can replace the room with a Merge over everyone's, doubling other members' rows
|
|
33
|
+
* in the team room.
|
|
34
|
+
*
|
|
35
|
+
* ALTER UPDATE and ALTER DELETE both: `DELETE FROM` is a lightweight delete implemented as
|
|
36
|
+
* `ALTER TABLE … UPDATE _row_exists = 0`, and which of the two a server demands varies by
|
|
37
|
+
* version. ALTER ADD COLUMN for ensureSchema's rollout. Nothing that defines an object.
|
|
38
|
+
*/
|
|
39
|
+
const MEMBER_PRIVS = 'SELECT, INSERT, ALTER UPDATE, ALTER DELETE, ALTER ADD COLUMN, OPTIMIZE';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resource ceilings for a member, as a settings profile.
|
|
43
|
+
*
|
|
44
|
+
* Here for the same reason MEMBER_PRIVS is here: there are two paths that provision a
|
|
45
|
+
* house — provision.js and `install --print-sql` — and a change made to one of them and
|
|
46
|
+
* not the other has now happened eight times. Anything a member is GIVEN belongs in this
|
|
47
|
+
* file, so both paths read the same definition.
|
|
48
|
+
*
|
|
49
|
+
* MIN..MAX, not CONST and not MAX alone.
|
|
50
|
+
*
|
|
51
|
+
* A plain default is advisory — a member writes `SETTINGS max_memory_usage = …` on their
|
|
52
|
+
* query and overrides it. MAX alone is not enough either: in ClickHouse **0 means
|
|
53
|
+
* unlimited** for these, and 0 satisfies any MAX, so `SETTINGS max_memory_usage = 0`
|
|
54
|
+
* removed the ceiling entirely. MIN 1 closes that.
|
|
55
|
+
*
|
|
56
|
+
* CONST closes it too and was tried first — but CONST refuses EVERY change, including the
|
|
57
|
+
* server's own. `rawQuery` in memhouse/server/queries.js pins max_result_rows and
|
|
58
|
+
* max_execution_time to protect the dashboard from an unbounded query, and under CONST
|
|
59
|
+
* those pins were refused with Code 452, so /api/query returned an error for every input
|
|
60
|
+
* on any house provisioned after that change. A ceiling has to stop a member RAISING the
|
|
61
|
+
* limit while still letting anyone LOWER it.
|
|
62
|
+
*
|
|
63
|
+
* max_memory_usage is per QUERY; max_memory_usage_for_user bounds the member across
|
|
64
|
+
* concurrent queries, without which N parallel queries x the per-query ceiling is
|
|
65
|
+
* unbounded.
|
|
66
|
+
*
|
|
67
|
+
* Limits are generous on purpose: the point is to stop one member exhausting a shared
|
|
68
|
+
* server, not to make an honest full re-ship fail — a 20M-row scan runs inside them.
|
|
69
|
+
*
|
|
70
|
+
* KNOWN GAP, deliberately not closed here: ALTER UPDATE, ALTER DELETE and OPTIMIZE are in
|
|
71
|
+
* MEMBER_PRIVS and execute in the background merge pool, where these per-query settings do
|
|
72
|
+
* not apply. A member looping `OPTIMIZE … FINAL` on a large room can still load the box.
|
|
73
|
+
* Bounding that needs server-level merge-pool configuration, which is the operator's, not
|
|
74
|
+
* something a per-member profile can express.
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* The global-install command that will actually work HERE.
|
|
78
|
+
*
|
|
79
|
+
* Every site that printed `npm install -g memhouse --allow-scripts=better-sqlite3` printed
|
|
80
|
+
* a line that fails with EACCES on any distro-packaged Node, because the global prefix is
|
|
81
|
+
* root-owned — measured on a clean Ubuntu machine, exit 243, including as step 1 of
|
|
82
|
+
* `prompt --install`, which advertises itself as rendered for this machine. The README
|
|
83
|
+
* learned this; eight code sites had not.
|
|
84
|
+
*
|
|
85
|
+
* `sudo` is right for exactly one of the two causes and makes the other worse, and what
|
|
86
|
+
* tells them apart is who owns the prefix — so look, rather than guess.
|
|
87
|
+
*/
|
|
88
|
+
function installCommand() {
|
|
89
|
+
const base = 'npm install -g memhouse --allow-scripts=better-sqlite3';
|
|
90
|
+
try {
|
|
91
|
+
const { execFileSync } = require('child_process');
|
|
92
|
+
const prefix = execFileSync('npm', ['prefix', '-g'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
93
|
+
const st = require('fs').statSync(prefix);
|
|
94
|
+
if (st.uid !== process.getuid()) return `sudo ${base}`;
|
|
95
|
+
} catch { /* npm not on PATH, or an unreadable prefix: fall through to the plain form */ }
|
|
96
|
+
return base;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const MEMBER_PROFILE = 'memhouse_member';
|
|
100
|
+
const MEMBER_PROFILE_SETTINGS = [
|
|
101
|
+
'max_memory_usage = 8000000000 MIN 1 MAX 8000000000',
|
|
102
|
+
'max_memory_usage_for_user = 16000000000 MIN 1 MAX 16000000000',
|
|
103
|
+
'max_execution_time = 600 MIN 1 MAX 600',
|
|
104
|
+
'max_threads = 16 MIN 1 MAX 16',
|
|
105
|
+
'max_result_rows = 10000000 MIN 1 MAX 10000000',
|
|
106
|
+
].join(', ');
|
|
107
|
+
|
|
108
|
+
// ClickHouse usernames are permissive; room names are not. Refuse anything that would
|
|
109
|
+
// need quoting or could change how a Merge regex or a name-splitter reads.
|
|
110
|
+
function assertUsableMember(member) {
|
|
111
|
+
if (typeof member !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) {
|
|
112
|
+
throw new Error(`cannot build room names for user '${member}': expected [A-Za-z][A-Za-z0-9_]*`);
|
|
113
|
+
}
|
|
114
|
+
// The reservation lives HERE, not only in the CLI, because provision.js is the admin
|
|
115
|
+
// entry point INSTALL.md sends you to for a second member — and it went as far as
|
|
116
|
+
// creating sessions_root/messages_root/tool_calls_root before dying on the grant,
|
|
117
|
+
// leaving three orphan rooms nothing removes. Case-insensitive: Root and ROOT are the
|
|
118
|
+
// same account. `default` is deliberately NOT reserved — a poor member name, but a real
|
|
119
|
+
// one that existing houses are built on.
|
|
120
|
+
if (member.toLowerCase() === 'root') {
|
|
121
|
+
throw new Error("'root' cannot be a member: it is a container artefact, not a person");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The session rollup, as SQL text rather than a name.
|
|
127
|
+
*
|
|
128
|
+
* Two properties are load bearing: the join is a LEFT JOIN, so a session with no messages
|
|
129
|
+
* still appears with zero aggregates instead of vanishing from every count; and
|
|
130
|
+
* `join_use_nulls = 1` makes the unmatched message columns NULL so `coalesce` yields true
|
|
131
|
+
* zeros rather than counting the placeholder row.
|
|
132
|
+
*
|
|
133
|
+
* BOTH ARE NOW CARRIED BY THE TEXT ITSELF — `FINAL` on each room and a trailing
|
|
134
|
+
* `SETTINGS join_use_nulls = 1`. It used to depend on the caller passing `final=1` and
|
|
135
|
+
* `join_use_nulls=1`, on the belief that a subquery cannot carry its own SETTINGS. It can
|
|
136
|
+
* (verified on 26.7 and 25.11), and the belief was expensive: every consumer that forgot
|
|
137
|
+
* the settings silently counted each message once per undeleted ReplacingMergeTree
|
|
138
|
+
* version. Measured right after a `ship --full` — 2x, and 3x three ships later. It does
|
|
139
|
+
* not self-heal; the error grows with each pass until a merge happens to collapse the
|
|
140
|
+
* parts. `memhouse sessions-query > q.sql` lost the warning entirely, because it was
|
|
141
|
+
* printed on stderr as a SQL comment that never reached the SQL.
|
|
142
|
+
*
|
|
143
|
+
* Passing final=1 as well is harmless, so READ_SETTINGS stays as it is for direct room
|
|
144
|
+
* reads, which still need it.
|
|
145
|
+
*/
|
|
146
|
+
function sessionsRollup({ sessions, messages }) {
|
|
147
|
+
return `(
|
|
148
|
+
SELECT
|
|
149
|
+
s.session_id AS session_id,
|
|
150
|
+
any(s.source) AS source,
|
|
151
|
+
any(s.host) AS host,
|
|
152
|
+
any(s.name) AS name,
|
|
153
|
+
any(s.mode) AS mode,
|
|
154
|
+
any(s.folder) AS folder,
|
|
155
|
+
any(s.project) AS project,
|
|
156
|
+
any(s.git_branch) AS git_branch,
|
|
157
|
+
s.user_id AS user_id,
|
|
158
|
+
any(s.created_at) AS created_at,
|
|
159
|
+
any(s.last_updated_at) AS last_updated_at,
|
|
160
|
+
min(m.ts) AS started,
|
|
161
|
+
max(m.ts) AS ended,
|
|
162
|
+
coalesce(dateDiff('second', min(m.ts), max(m.ts)), 0) AS duration_sec,
|
|
163
|
+
count(m.seq) AS total_msgs,
|
|
164
|
+
countIf(m.role = 'user') AS user_msgs,
|
|
165
|
+
countIf(m.role = 'assistant') AS assistant_msgs,
|
|
166
|
+
countIf(m.is_subagent) AS subagent_msgs,
|
|
167
|
+
groupUniqArrayIf(m.model, m.model NOT IN ('', '<synthetic>')) AS models,
|
|
168
|
+
coalesce(sum(m.input_tokens), 0) AS input_tokens,
|
|
169
|
+
coalesce(sum(m.output_tokens), 0) AS output_tokens,
|
|
170
|
+
coalesce(sum(m.cache_read_tokens), 0) AS cache_read_tokens,
|
|
171
|
+
coalesce(sum(m.cache_write_tokens), 0) AS cache_write_tokens,
|
|
172
|
+
coalesce(sumIf(length(m.text), m.role = 'user'), 0) AS user_chars,
|
|
173
|
+
coalesce(sumIf(length(m.text), m.role = 'assistant'), 0) AS assistant_chars,
|
|
174
|
+
coalesce(substring(argMinIf(m.text, m.seq, m.role = 'user' AND m.text != ''), 1, 200), '') AS first_prompt
|
|
175
|
+
FROM ${sessions} AS s FINAL
|
|
176
|
+
LEFT JOIN ${messages} AS m FINAL ON m.session_id = s.session_id AND m.user_id = s.user_id
|
|
177
|
+
GROUP BY s.session_id, s.user_id
|
|
178
|
+
SETTINGS join_use_nulls = 1
|
|
179
|
+
)`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The settings every read needs. `final` collapses ReplacingMergeTree versions;
|
|
184
|
+
* `join_use_nulls` is what the rollup's coalesce depends on.
|
|
185
|
+
*/
|
|
186
|
+
const READ_SETTINGS = { final: 1, join_use_nulls: 1 };
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Names, given the member the server reported. Shared by every caller so the two
|
|
190
|
+
* transports in the tree (@clickhouse/client and the CLI's raw fetch) cannot drift into
|
|
191
|
+
* two different naming rules.
|
|
192
|
+
*
|
|
193
|
+
* `sessions_v` is the rollup as SQL text, usable in the same `FROM ... AS c` position a
|
|
194
|
+
* view name would occupy — which is what lets the read layer treat it as just another
|
|
195
|
+
* resolved name.
|
|
196
|
+
*/
|
|
197
|
+
function roomNames(member, user = member) {
|
|
198
|
+
assertUsableMember(member);
|
|
199
|
+
const out = { member, user };
|
|
200
|
+
for (const t of ROOM_TYPES) out[t] = `${t}_${member}`;
|
|
201
|
+
out.sessions_v = sessionsRollup(out);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function currentUser(client) {
|
|
206
|
+
const rs = await client.query({ query: 'SELECT currentUser() AS u', format: 'JSONEachRow' });
|
|
207
|
+
const rows = await rs.json();
|
|
208
|
+
const u = rows[0] && rows[0].u;
|
|
209
|
+
if (!u) throw new Error('could not determine currentUser()');
|
|
210
|
+
return u;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolve the rooms for whoever this client is connected as.
|
|
215
|
+
* Returns { sessions, messages, tool_calls, sessions_v, member, user }.
|
|
216
|
+
*
|
|
217
|
+
* `user` is the identity the server reports. Writers need the value itself, not just the
|
|
218
|
+
* name it produces — see ship.js's delete, which must BIND the user rather than call
|
|
219
|
+
* currentUser() inside a mutation, where it is not evaluated in the caller's context.
|
|
220
|
+
*/
|
|
221
|
+
async function resolveRooms(client) {
|
|
222
|
+
const user = await currentUser(client);
|
|
223
|
+
return roomNames(user, user);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The three Merge rooms — team-wide read paths, owner-managed. */
|
|
227
|
+
function mergeRooms() {
|
|
228
|
+
return { sessions: 'all_sessions', messages: 'all_messages', tool_calls: 'all_tool_calls' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
MEMBER_PRIVS, installCommand, MEMBER_PROFILE, MEMBER_PROFILE_SETTINGS,
|
|
233
|
+
ROOM_TYPES, READ_SETTINGS, assertUsableMember,
|
|
234
|
+
sessionsRollup, roomNames, currentUser, resolveRooms, mergeRooms,
|
|
235
|
+
};
|