memhouse 0.18.8 → 0.18.10

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 (45) hide show
  1. package/AGENTS.md +194 -0
  2. package/CHANGELOG.md +970 -0
  3. package/README.md +54 -423
  4. package/SECURITY.md +68 -0
  5. package/bin/memhouse.js +202 -92
  6. package/docs/README.md +46 -0
  7. package/docs/guides/admin-password.md +95 -0
  8. package/docs/guides/configuration.md +109 -0
  9. package/docs/guides/daemons.md +53 -0
  10. package/docs/guides/install.md +128 -0
  11. package/docs/guides/invite.md +87 -0
  12. package/docs/guides/operate.md +112 -0
  13. package/docs/guides/search-and-resume.md +95 -0
  14. package/docs/guides/share.md +102 -0
  15. package/docs/guides/storage.md +90 -0
  16. package/docs/guides/troubleshooting.md +78 -0
  17. package/docs/guides/uninstall.md +35 -0
  18. package/docs/guides/update.md +68 -0
  19. package/docs/releases/README.md +10 -0
  20. package/docs/releases/v0.18.10.md +226 -0
  21. package/docs/tutorials/01-first-house.md +132 -0
  22. package/docs/tutorials/02-join-a-house.md +91 -0
  23. package/docs/tutorials/03-share-your-memory.md +96 -0
  24. package/examples/01-local-one-machine/README.md +99 -0
  25. package/examples/01-local-one-machine/run.sh +61 -0
  26. package/examples/02-existing-clickhouse/README.md +121 -0
  27. package/examples/02-existing-clickhouse/run.sh +71 -0
  28. package/examples/03-team-house/README.md +129 -0
  29. package/examples/03-team-house/run.sh +124 -0
  30. package/examples/04-self-hosted-team/.env.example +22 -0
  31. package/examples/04-self-hosted-team/README.md +187 -0
  32. package/examples/04-self-hosted-team/compose.yml +158 -0
  33. package/examples/04-self-hosted-team/smoke.sh +113 -0
  34. package/examples/README.md +42 -0
  35. package/examples/fixtures/README.md +24 -0
  36. package/examples/fixtures/make-sessions.js +63 -0
  37. package/memhouse/admin-secret.js +81 -0
  38. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  39. package/memhouse/deploy.js +4 -2
  40. package/memhouse/flags.js +4 -4
  41. package/memhouse/provision.js +12 -3
  42. package/memhouse/server/queries.js +18 -4
  43. package/memhouse/server/server.js +9 -1
  44. package/memhouse/service.js +5 -0
  45. package/package.json +10 -2
@@ -0,0 +1,129 @@
1
+ # 03 — a team house
2
+
3
+ One house, three people. An administrator invites alice, bob and carol; each installs on
4
+ their own machine and ships. Then alice shares her memory two ways — everything with bob,
5
+ one project with carol — and the script reads with each person's own credential to show
6
+ exactly who can see what. Finally she withdraws both shares.
7
+
8
+ ## The model in four lines
9
+
10
+ - Every member owns `mem.<member>_*`: five rooms (sessions, messages, tool_calls, meta,
11
+ events) and three stat tables. One grant on that pattern is their whole access.
12
+ - Housemates cannot read, list, or touch each other's rooms.
13
+ - A **full share** (`memhouse share bob`) grants `SELECT ON mem.alice_*`: every room,
14
+ every project, every machine, and anything ever pasted into a session.
15
+ - A **scoped share** (`memhouse share carol --only project=alpha`) grants the three
16
+ transcript rooms only, each behind a row policy. The stat tables, meta and events are
17
+ not shared at all, because they carry no per-session filter.
18
+
19
+ ## What it does
20
+
21
+ 1. The admin runs `memhouse invite <name> --url <house>` three times. Each writes
22
+ `<name>.env`: the member's URL, user, password and house. **That file is a password**;
23
+ hand it over the way you would hand over a password.
24
+ 2. Each member runs `memhouse install --env <name>.env` in their own home. With `--yes`
25
+ the install also rotates the invited password to one only that machine knows, and
26
+ deletes the spent file. Then each ships two made-up sessions (projects `alpha` and
27
+ `beta`).
28
+ 3. alice: `memhouse share bob`, `memhouse share carol --only project=alpha`,
29
+ `memhouse share --list`.
30
+ 4. Reads, each with that person's own credential, straight against ClickHouse.
31
+ 5. alice: `memhouse share bob --revoke`, `memhouse share carol --revoke`; the reads are
32
+ denied again.
33
+
34
+ At exit the script drops the three users and their rooms (`KEEP=1` keeps them).
35
+
36
+ ## Needs
37
+
38
+ - Node.js 24 or newer, `npm install -g memhouse`, and `curl`
39
+ - A **throwaway** ClickHouse and its administrator — a scratch container, a lab. The script
40
+ refuses to start if users named alice, bob or carol exist, and at exit it drops the ones
41
+ it created. [04](../04-self-hosted-team/) stands one up, or
42
+ a scratch container (the password comes from your environment, not the command line;
43
+ remove it afterwards with `docker rm -f -v memhouse-scratch`):
44
+
45
+ ```bash
46
+ CLICKHOUSE_PASSWORD="$MEMHOUSE_ADMIN_PASSWORD" docker run -d --name memhouse-scratch \
47
+ -p 127.0.0.1:8123:8123 -e CLICKHOUSE_USER=admin -e CLICKHOUSE_PASSWORD \
48
+ -e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 clickhouse/clickhouse-server:26.7.1.1315
49
+ ```
50
+
51
+ ## Run
52
+
53
+ ```bash
54
+ export MEMHOUSE_URL=https://memhouse.example.com # what the members will reach
55
+ export MEMHOUSE_ADMIN_USER=admin
56
+ with-secret MEMHOUSE_ADMIN_PASSWORD=keychain:clickhouse-admin -- ./run.sh
57
+ ```
58
+
59
+ A `localhost` URL works too (the script passes `--allow-local`); for real members it is
60
+ wrong, because their `localhost` is their own machine.
61
+
62
+ | Variable | Default | |
63
+ |---|---|---|
64
+ | `MEMHOUSE_URL` | (required) | the house endpoint the members use |
65
+ | `MEMHOUSE_ADMIN_USER` / `MEMHOUSE_ADMIN_PASSWORD` | (required) | the administrator |
66
+ | `MEMHOUSE_BIN` | `memhouse` | the command to run |
67
+ | `EXAMPLE_DIR` | a new temp dir | where the three homes live |
68
+ | `KEEP` | `0` | `1` keeps the members and their rooms |
69
+
70
+ ## What you should see
71
+
72
+ From a run against ClickHouse 26.7.1.1315, memhouse 0.18.9 (temp paths shortened):
73
+
74
+ ```
75
+ == 1. the admin invites three members
76
+ ✓ invite written: …/alice.env
77
+ ✓ invite written: …/bob.env
78
+ ✓ invite written: …/carol.env
79
+
80
+ == 2. each member installs from their file, on their own machine (here: their own home)
81
+ alice: ✓ installed
82
+ alice: ✓ password rotated — this credential is now yours alone
83
+ alice: ✓ removed the spent invite file …/alice.env
84
+ alice: [memhouse] shipped 2 sessions (0 skipped) → 6 msg rows, 2 tool rows in 0.3s
85
+ (bob and carol the same)
86
+
87
+ == 3. alice shares: everything with bob, project alpha only with carol
88
+ ✓ 'bob' can now read every session in your rooms (mem.alice_*)
89
+ ✓ 'carol' can read your rooms in 'mem' where project=alpha
90
+ who can read your rooms in 'mem' — memhouse's own record, not ClickHouse's grant table:
91
+ bob granted 2026-10-07 (all your rooms)
92
+ carol granted 2026-10-07 scope=project=alpha
93
+ alice_messages project = 'alpha'
94
+ alice_sessions project = 'alpha'
95
+ alice_tool_calls project = 'alpha'
96
+
97
+ == 4. who reads what — each with their own credential
98
+ alice, her own messages by project alpha,beta 6
99
+ bob (full share), alice's messages alpha,beta 6
100
+ carol (alpha only), alice's messages alpha 3
101
+ bob, alice's session_stats 2 rows
102
+ carol, alice's session_stats denied
103
+ carol, alice's meta denied
104
+ carol, bob's messages (never shared) denied
105
+
106
+ == 5. alice withdraws both shares
107
+ ✓ revoked 'bob' — SELECT withdrawn and 3 row policies dropped
108
+ ✓ revoked 'carol' — SELECT withdrawn and 3 row policies dropped
109
+ bob, alice's messages denied
110
+ carol, alice's messages denied
111
+ removed alice, bob, carol, their rooms and their homes
112
+ ```
113
+
114
+ The revoke line for bob counts the three policy drops memhouse attempts on every revoke;
115
+ a full share has no policies, so none existed to drop.
116
+
117
+ ## For real
118
+
119
+ - **The admin** runs `memhouse invite <name> --url <house URL>` once per person and hands
120
+ each file over a trusted channel (a password manager, `croc`, in person) — not chat or
121
+ e-mail. Add `--admin-user` when the admin credential is not kept on that machine; the
122
+ password comes from `MEMHOUSE_ADMIN_PASSWORD` or `--admin-password-file -`.
123
+ - **Each member** runs `memhouse install --env <name>.env` on their laptop, without the
124
+ fixture flags, then `memhouse start` (or `memhouse service install`) to keep shipping.
125
+ - **Sharing** is the member's own decision and needs no admin: `memhouse share <user>`,
126
+ `--only project=<p>` (also `session=`, `host=`, `source=`, `folder=`, `since=`,
127
+ `until=`), `--revoke`, `--list`.
128
+ - A grantee reads the shared rooms with their own credential: `/mem:recall` and the other
129
+ agent skills, or SQL against `mem.<owner>_messages`.
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bash
2
+ # Example 03 — a team house: one admin, three members, two kinds of share.
3
+ #
4
+ # The admin invites alice, bob and carol into one house. Each installs from their invite
5
+ # file into a home of their own (standing in for three laptops), and ships made-up
6
+ # sessions. Then alice shares: everything with bob, project `alpha` only with carol. The
7
+ # script reads with each person's own credential to show what they can and cannot see,
8
+ # and finally revokes both shares.
9
+ #
10
+ # Run it against a THROWAWAY ClickHouse — a lab, a scratch container. It refuses to start
11
+ # if users named alice, bob or carol already exist.
12
+ #
13
+ # with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- ./run.sh
14
+ # KEEP=1 ... keep the three members and their rooms afterwards (default: removed)
15
+ #
16
+ # Environment:
17
+ # MEMHOUSE_URL required the house's HTTP endpoint, as the members will reach it
18
+ # MEMHOUSE_ADMIN_USER required an account that can create users and grant
19
+ # MEMHOUSE_ADMIN_PASSWORD required its password — lent by with-secret, never on argv
20
+ # MEMHOUSE_BIN optional the memhouse command (default: memhouse)
21
+ # EXAMPLE_DIR optional where the three homes live (default: a new temp dir)
22
+ set -euo pipefail
23
+
24
+ HERE=$(cd "$(dirname "$0")" && pwd)
25
+ MH=${MEMHOUSE_BIN:-memhouse}
26
+ mh() { $MH "$@"; }
27
+ : "${MEMHOUSE_URL:?set MEMHOUSE_URL to the house endpoint the members will use}"
28
+ : "${MEMHOUSE_ADMIN_USER:?set MEMHOUSE_ADMIN_USER}"
29
+ : "${MEMHOUSE_ADMIN_PASSWORD:?lend it with: with-secret MEMHOUSE_ADMIN_PASSWORD=<reference> -- $0}"
30
+ unset MEMHOUSE_USER MEMHOUSE_PASSWORD MEMHOUSE_DB
31
+ URL=$MEMHOUSE_URL
32
+ WORK=${EXAMPLE_DIR:-$(mktemp -d)}
33
+ mkdir -p "$WORK"
34
+ PEOPLE="alice bob carol"
35
+
36
+ # SQL over HTTP with the password fed to curl on stdin (-K -), so it is never in argv.
37
+ admin_q() {
38
+ printf 'user = "%s:%s"\n' "$MEMHOUSE_ADMIN_USER" "$MEMHOUSE_ADMIN_PASSWORD" \
39
+ | curl -sS -K - --data-binary "$1" "$URL/"
40
+ }
41
+ # The same, as one member, with the credential their own install keeps.
42
+ as() {
43
+ local who=$1 sql=$2
44
+ ( set -a; . "$WORK/$who/env"; set +a
45
+ printf 'user = "%s:%s"\n' "$MEMHOUSE_USER" "$MEMHOUSE_PASSWORD" ) \
46
+ | curl -sS -K - --data-binary "$sql" "$URL/" 2>&1 | head -1
47
+ }
48
+ denied() { case "$1" in *ACCESS_DENIED*|*"Not enough privileges"*) return 0;; *) return 1;; esac; }
49
+
50
+ cleanup() {
51
+ [ "${KEEP:-0}" = "1" ] && { echo "KEEP=1: members and rooms left in place; homes in $WORK"; return; }
52
+ [ "${CREATED:-0}" = "1" ] || return 0
53
+ for p in $PEOPLE; do
54
+ admin_q "DROP USER IF EXISTS $p" >/dev/null 2>&1 || true
55
+ for t in $(admin_q "SELECT name FROM system.tables WHERE database = 'mem' AND startsWith(name, '${p}_') FORMAT TSV" 2>/dev/null); do
56
+ admin_q "DROP TABLE IF EXISTS mem.$t SYNC" >/dev/null 2>&1 || true
57
+ done
58
+ done
59
+ rm -rf "$WORK"
60
+ echo "removed alice, bob, carol, their rooms and their homes"
61
+ }
62
+ trap cleanup EXIT
63
+
64
+ # Refuse anything this run did not make: the users, and any table under their room prefixes
65
+ # (orphaned rooms can outlive their users). The cleanup below drops by those prefixes, so it
66
+ # may only run when this check proved them empty first.
67
+ existing=$(admin_q "SELECT count() FROM system.users WHERE name IN ('alice','bob','carol') FORMAT TSV")
68
+ [ "$existing" = "0" ] || { echo "refusing: users alice/bob/carol already exist on this server — use a throwaway ClickHouse"; exit 1; }
69
+ rooms=$(admin_q "SELECT count() FROM system.tables WHERE database = 'mem' AND (startsWith(name, 'alice_') OR startsWith(name, 'bob_') OR startsWith(name, 'carol_')) FORMAT TSV")
70
+ [ "$rooms" = "0" ] || { echo "refusing: mem.alice_*/bob_*/carol_* tables already exist ($rooms) — use a throwaway ClickHouse"; exit 1; }
71
+ CREATED=1
72
+
73
+ # A loopback URL is right only through a tunnel; invite refuses one unless told so.
74
+ case "$URL" in http://localhost*|http://127.0.0.1*|https://localhost*|https://127.0.0.1*) LOCAL=--allow-local;; *) LOCAL=;; esac
75
+
76
+ echo "== 1. the admin invites three members"
77
+ # The admin needs no install of their own: an empty home, so nothing stored is read.
78
+ export MEMHOUSE_HOME="$WORK/admin"; mkdir -p "$MEMHOUSE_HOME"
79
+ for p in $PEOPLE; do
80
+ # One env file per person. It IS a password: hand it over through a trusted channel.
81
+ out=$(cd "$WORK" && mh invite "$p" --url "$URL" --admin-user "$MEMHOUSE_ADMIN_USER" --out "$WORK/$p.env" $LOCAL 2>&1) \
82
+ || { printf '%s\n' "$out"; exit 1; }
83
+ printf '%s\n' "$out" | grep "invite written"
84
+ done
85
+
86
+ echo
87
+ echo "== 2. each member installs from their file, on their own machine (here: their own home)"
88
+ for p in $PEOPLE; do
89
+ export MEMHOUSE_HOME="$WORK/$p"; mkdir -p "$MEMHOUSE_HOME"
90
+ node "$HERE/../fixtures/make-sessions.js" "$MEMHOUSE_HOME/sessions" "$p" alpha beta >/dev/null
91
+ # --yes also rotates the invited password to one only this home knows, and deletes the file.
92
+ out=$(unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD
93
+ mh install --env "$WORK/$p.env" --editors claude --claude-roots "$MEMHOUSE_HOME/sessions" --yes --no-ship 2>&1) \
94
+ || { printf '%s\n' "$out"; exit 1; }
95
+ printf '%s\n' "$out" | grep -E "installed$|rotat|removed the spent" | sed "s/^/ $p: /" || true
96
+ (unset MEMHOUSE_ADMIN_USER MEMHOUSE_ADMIN_PASSWORD; mh ship) | sed "s/^/ $p: /"
97
+ done
98
+ unset MEMHOUSE_HOME
99
+
100
+ echo
101
+ echo "== 3. alice shares: everything with bob, project alpha only with carol"
102
+ export MEMHOUSE_HOME="$WORK/alice"
103
+ mh share bob --yes
104
+ mh share carol --only project=alpha --yes
105
+ mh share --list
106
+
107
+ echo
108
+ echo "== 4. who reads what — each with their own credential"
109
+ show() { printf ' %-44s %s\n' "$1" "$2"; }
110
+ show "alice, her own messages by project" "$(as alice "SELECT arrayStringConcat(arraySort(groupUniqArray(project)), ',') || ' ' || toString(count()) FROM mem.alice_messages FORMAT TSV")"
111
+ show "bob (full share), alice's messages" "$(as bob "SELECT arrayStringConcat(arraySort(groupUniqArray(project)), ',') || ' ' || toString(count()) FROM mem.alice_messages FORMAT TSV")"
112
+ show "carol (alpha only), alice's messages" "$(as carol "SELECT arrayStringConcat(arraySort(groupUniqArray(project)), ',') || ' ' || toString(count()) FROM mem.alice_messages FORMAT TSV")"
113
+ r=$(as bob "SELECT count() FROM mem.alice_session_stats FORMAT TSV"); show "bob, alice's session_stats" "$(denied "$r" && echo denied || echo "$r rows")"
114
+ r=$(as carol "SELECT count() FROM mem.alice_session_stats FORMAT TSV"); show "carol, alice's session_stats" "$(denied "$r" && echo denied || echo "$r rows")"
115
+ r=$(as carol "SELECT count() FROM mem.alice_meta FORMAT TSV"); show "carol, alice's meta" "$(denied "$r" && echo denied || echo "$r rows")"
116
+ r=$(as carol "SELECT count() FROM mem.bob_messages FORMAT TSV"); show "carol, bob's messages (never shared)" "$(denied "$r" && echo denied || echo "$r rows")"
117
+
118
+ echo
119
+ echo "== 5. alice withdraws both shares"
120
+ mh share bob --revoke --yes
121
+ mh share carol --revoke --yes
122
+ r=$(as bob "SELECT count() FROM mem.alice_messages FORMAT TSV"); show "bob, alice's messages" "$(denied "$r" && echo denied || echo "$r rows")"
123
+ r=$(as carol "SELECT count() FROM mem.alice_messages FORMAT TSV"); show "carol, alice's messages" "$(denied "$r" && echo denied || echo "$r rows")"
124
+ unset MEMHOUSE_HOME
@@ -0,0 +1,22 @@
1
+ # Copy to .env beside compose.yml and chmod 600 it: it holds the admin password.
2
+ # cp .env.example .env && chmod 600 .env
3
+
4
+ # The name members reach the house at. DNS must point it at this machine.
5
+ MEMHOUSE_DOMAIN=memhouse.example.com
6
+ # An e-mail address gets a Let's Encrypt certificate (ports 80 and 443 must be reachable
7
+ # from the internet). `internal` uses Caddy's own CA instead — for testing, or a private
8
+ # network whose machines trust that CA.
9
+ MEMHOUSE_TLS=you@example.com
10
+
11
+ # The server's administrator. Invites members; nothing ships as it.
12
+ MEMHOUSE_ADMIN_USER=admin
13
+ # Generate one: openssl rand -base64 24
14
+ MEMHOUSE_ADMIN_PASSWORD=
15
+
16
+ # Nightly backup, UTC, and how many to keep.
17
+ BACKUP_AT=03:17
18
+ BACKUP_KEEP=14
19
+
20
+ # Pinned versions. Read README.md "Upgrading ClickHouse" before changing CLICKHOUSE_TAG.
21
+ CLICKHOUSE_TAG=26.7.1.1315
22
+ CADDY_TAG=2.10
@@ -0,0 +1,187 @@
1
+ # 04 — a self-hosted house for a team
2
+
3
+ The full deployment: ClickHouse on a server you run, behind TLS, backed up every night,
4
+ with members shipping from their own laptops. One compose file holds all of it.
5
+
6
+ ```
7
+ member laptops your server (docker compose)
8
+ ───────────── ─────────────────────────────────────────────
9
+ memhouse shipper ──https:443──▶ caddy ──http──▶ clickhouse ◀── backup (nightly)
10
+ /mem skills, dashboard (TLS) (not published) │
11
+ │ data volume ▼ backups volume
12
+ ```
13
+
14
+ | Service | Image | Role |
15
+ |---|---|---|
16
+ | `clickhouse` | `clickhouse/clickhouse-server:26.7.1.1315` | the house. No published port; reachable only through Caddy. |
17
+ | `caddy` | `caddy:2.10` | TLS on 443: a Let's Encrypt certificate for your domain, or Caddy's own CA. Port 80 answers the ACME challenge and redirects. |
18
+ | `backup` | the ClickHouse image | at `BACKUP_AT` (UTC) writes the house **and its accounts** to the `backups` volume; keeps the newest `BACKUP_KEEP` (14). |
19
+
20
+ ClickHouse is configured inline (`configs:` in [compose.yml](compose.yml)): the `backups`
21
+ disk, and `users_without_row_policies_can_read_rows` set explicitly to `true`, which
22
+ memhouse's scoped shares depend on (`memhouse share --only` measures it and refuses when it
23
+ is off). The image creates your admin account at first start and removes the passwordless
24
+ `default` user.
25
+
26
+ ## Needs
27
+
28
+ - A Linux server with Docker and Compose v2.23.1 or newer (`docker compose version`)
29
+ - A DNS name pointing at it, with ports 80 and 443 reachable from the internet for a Let's
30
+ Encrypt certificate. On a private network, use `MEMHOUSE_TLS=internal` instead (see
31
+ [TLS](#tls)).
32
+ - On each member's machine: Node.js 24 and `npm install -g memhouse`
33
+
34
+ ## Set up
35
+
36
+ ```bash
37
+ cp .env.example .env && chmod 600 .env
38
+ $EDITOR .env # MEMHOUSE_DOMAIN, MEMHOUSE_TLS, MEMHOUSE_ADMIN_PASSWORD (openssl rand -base64 24)
39
+ docker compose up -d
40
+ ./smoke.sh # optional: proves the stack end to end, then cleans up after itself
41
+ ```
42
+
43
+ `.env` holds the admin password; it is git-ignored and should stay mode 600. Keep a copy
44
+ of the password in your secret store: you need it to invite people and to restore.
45
+
46
+ ### TLS
47
+
48
+ - **Public server** — `MEMHOUSE_TLS=you@example.com`. Caddy gets and renews a Let's Encrypt
49
+ certificate for `MEMHOUSE_DOMAIN`. Members need nothing extra.
50
+ - **Private network** — `MEMHOUSE_TLS=internal`. Caddy signs with its own CA. Every client
51
+ must trust it: copy it out once and point Node at it on each member machine.
52
+
53
+ ```bash
54
+ docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./memhouse-ca.crt
55
+ # on each member machine, in the shell profile or the service environment:
56
+ export NODE_EXTRA_CA_CERTS=/path/to/memhouse-ca.crt
57
+ ```
58
+
59
+ ## Invite members
60
+
61
+ From any machine with memhouse, with the admin password lent for one command:
62
+
63
+ ```bash
64
+ with-secret MEMHOUSE_ADMIN_PASSWORD=keychain:memhouse-admin -- \
65
+ memhouse invite alice --url https://memhouse.example.com --admin-user admin
66
+ ```
67
+
68
+ That creates the user `alice`, grants her `mem.alice_*` and nothing else, and writes
69
+ `invite-alice.env`. **The file is a password**: hand it over through a password manager or
70
+ `croc`, not chat. [03](../03-team-house/) walks through invites and sharing in detail.
71
+
72
+ ## Members: install and keep shipping
73
+
74
+ On alice's laptop:
75
+
76
+ ```bash
77
+ npm install -g memhouse
78
+ memhouse install --env invite-alice.env # offers to rotate the password, then deletes the file
79
+ memhouse service install # ship at login: systemd user unit or launchd agent
80
+ memhouse plugins install claude # /mem:recall and the other agent skills
81
+ ```
82
+
83
+ `memhouse service status` shows the unit; `memhouse status` shows freshness and every
84
+ machine writing to her rooms. Without a service, `memhouse start` runs the shipper and the
85
+ dashboard as background processes until logout.
86
+
87
+ ## Smoke test
88
+
89
+ `./smoke.sh` brings the stack up with `.env`, then, through the proxy only: checks the
90
+ certificate, that the `default` user is gone, invites a member `smoke`, installs it from
91
+ the invite file, ships two made-up sessions, reads them back as the member, backs up and
92
+ restores the member's messages, and checks the nightly job. At exit it drops `smoke` and
93
+ its rooms (`KEEP=1` keeps them). `SKIP_UP=1` tests a stack that is already running; see
94
+ the top of the script.
95
+
96
+ From a run with `MEMHOUSE_TLS=internal` on a Linux VM with Docker:
97
+
98
+ ```
99
+ smoke test against https://localhost:18443
100
+ ok the proxy answers over TLS (certificate verified)
101
+ ok the passwordless default user is gone
102
+ ok admin reaches ClickHouse 26.7.1.1315
103
+ ok admin invited member 'smoke'
104
+ ok the member installed from the invite file, over https
105
+ ok shipped: shipped 2 sessions (0 skipped) → 6 msg rows, 2 tool rows in 0.6s
106
+ ok the member reads its 6 messages back through the proxy
107
+ ok the member cannot create tables outside mem.smoke_*
108
+ ok BACKUP to the backups disk
109
+ ok RESTORE from it
110
+ ok the restored copy holds the same 6 rows
111
+ ok the backup service has written a nightly backup since the server started
112
+
113
+ 12 passed, 0 failed
114
+ ```
115
+
116
+ ## Backups and restore
117
+
118
+ Every night the `backup` service runs
119
+
120
+ ```sql
121
+ BACKUP DATABASE mem, TABLE system.users, TABLE system.roles, TABLE system.row_policies,
122
+ TABLE system.settings_profiles, TABLE system.quotas
123
+ TO Disk('backups', 'mem-YYYY-MM-DD-HH_MM.zip')
124
+ ```
125
+
126
+ and deletes all but the newest `BACKUP_KEEP`. The accounts are in the same file on
127
+ purpose: users, their password hashes, their grants and the row policies of scoped shares
128
+ live outside the database, and a restored house without them is one nobody can log in to.
129
+ `docker compose logs backup` shows each run.
130
+
131
+ **Get the backups off the server.** The `backups` volume sits on the same disk as the data;
132
+ copy the files somewhere else on a schedule of your own:
133
+
134
+ ```bash
135
+ # The files belong to ClickHouse inside the volume (mode 640); hand the copies to you.
136
+ docker run --rm -e OWNER="$(id -u):$(id -g)" -v memhouse-team_backups:/b:ro -v "$PWD":/out \
137
+ alpine sh -c 'cp /b/mem-*.zip /out/ && chown "$OWNER" /out/mem-*.zip'
138
+ ```
139
+
140
+ **Restore** onto a fresh stack (same compose file, same `.env`, empty volumes):
141
+
142
+ ```bash
143
+ docker compose up -d
144
+ docker compose cp mem-2026-10-07-21_17.zip clickhouse:/backups/
145
+ # the server reads backups as the clickhouse user; a copied-in file arrives as root
146
+ docker compose exec clickhouse chown clickhouse:clickhouse /backups/mem-2026-10-07-21_17.zip
147
+ ```
148
+
149
+ then, as the admin:
150
+
151
+ ```sql
152
+ RESTORE DATABASE mem, TABLE system.users, TABLE system.roles, TABLE system.row_policies,
153
+ TABLE system.settings_profiles, TABLE system.quotas
154
+ FROM Disk('backups', 'mem-2026-10-07-21_17.zip')
155
+ ```
156
+
157
+ Members come back with their own passwords and grants, so their machines keep shipping
158
+ once the DNS name points at the new server. Restoring accounts fails if a user of the same
159
+ name already exists, which is why this is a restore onto a fresh stack. To look inside a
160
+ backup without touching the live house, restore just the data under another name:
161
+ `RESTORE DATABASE mem AS mem_check FROM Disk('backups', '<file>')`, then
162
+ `DROP DATABASE mem_check`.
163
+
164
+ Tested on ClickHouse 26.7.1.1315: a nightly file copied off the server with the command
165
+ above, a fresh stack (`down -v`, `up`), the copy put back and restored — the data came
166
+ back, and the member logged in with its original password.
167
+
168
+ ## Upgrading ClickHouse
169
+
170
+ 1. Read the ClickHouse changelog between the two versions. memhouse's CI runs against
171
+ 25.11 and 26.7; memhouse adapts its text-index DDL to the server's grammar on its own.
172
+ 2. Take a backup now: `BACKUP_ON_START=1 docker compose up -d --force-recreate backup`
173
+ writes one immediately (check `docker compose logs backup`), and copy it off the server.
174
+ 3. Set `CLICKHOUSE_TAG` in `.env` to the new version, then
175
+ `docker compose pull clickhouse backup && docker compose up -d`.
176
+ 4. Check from a member machine: `memhouse doctor`, `memhouse status`. Or run
177
+ `SKIP_UP=1 ./smoke.sh` with the target variables set.
178
+
179
+ Do not go back to an older ClickHouse on the same data volume; going back means restoring
180
+ a backup onto the older version.
181
+
182
+ ## Teardown
183
+
184
+ ```bash
185
+ docker compose down # stops everything, keeps the data, backups and certificates
186
+ docker compose down -v # ALSO deletes the house, the backups and the certificates
187
+ ```
@@ -0,0 +1,158 @@
1
+ # memhouse — a self-hosted team house.
2
+ #
3
+ # Three services, one file:
4
+ # clickhouse the house. Not published: reachable only through the proxy.
5
+ # caddy TLS in front of ClickHouse's HTTP port. A real certificate from Let's
6
+ # Encrypt for MEMHOUSE_DOMAIN, or Caddy's own CA (MEMHOUSE_TLS=internal).
7
+ # backup the house and its accounts, every night, to their own volume; the
8
+ # last BACKUP_KEEP kept.
9
+ #
10
+ # Everything the containers need is inline (the `configs:` blocks), so this file is the
11
+ # whole deployment. Settings come from a `.env` file beside it — see .env.example.
12
+ #
13
+ # cp .env.example .env && chmod 600 .env # then edit it
14
+ # docker compose up -d
15
+ #
16
+ # Pinned to the ClickHouse version memhouse's own production house runs. Upgrading is
17
+ # changing CLICKHOUSE_TAG and `docker compose up -d` — see README.md first.
18
+ #
19
+ # Compose interpolates `$` everywhere in this file, config contents included, which is
20
+ # why the shell variables inside the backup script are written `$$`.
21
+
22
+ name: memhouse-team
23
+
24
+ services:
25
+ clickhouse:
26
+ image: docker.io/clickhouse/clickhouse-server:${CLICKHOUSE_TAG:-26.7.1.1315}
27
+ restart: unless-stopped
28
+ environment:
29
+ # The image creates this account at first start and removes the passwordless
30
+ # `default` user. It administers the server: it invites members, nothing ships as it.
31
+ CLICKHOUSE_USER: ${MEMHOUSE_ADMIN_USER:-admin}
32
+ CLICKHOUSE_PASSWORD: ${MEMHOUSE_ADMIN_PASSWORD:?set MEMHOUSE_ADMIN_PASSWORD in .env}
33
+ CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
34
+ configs:
35
+ - source: clickhouse-memhouse
36
+ target: /etc/clickhouse-server/config.d/memhouse.xml
37
+ volumes:
38
+ - data:/var/lib/clickhouse
39
+ - backups:/backups
40
+ ulimits:
41
+ nofile: { soft: 262144, hard: 262144 }
42
+ healthcheck:
43
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
44
+ interval: 5s
45
+ timeout: 3s
46
+ retries: 30
47
+ start_period: 10s
48
+
49
+ caddy:
50
+ image: docker.io/library/caddy:${CADDY_TAG:-2.10}
51
+ restart: unless-stopped
52
+ depends_on:
53
+ clickhouse: { condition: service_healthy }
54
+ environment:
55
+ MEMHOUSE_DOMAIN: ${MEMHOUSE_DOMAIN:?set MEMHOUSE_DOMAIN in .env}
56
+ MEMHOUSE_TLS: ${MEMHOUSE_TLS:-internal}
57
+ ports:
58
+ - "${MEMHOUSE_HTTPS_PORT:-443}:443"
59
+ # Port 80 answers Let's Encrypt's HTTP challenge and redirects everything else.
60
+ - "${MEMHOUSE_HTTP_PORT:-80}:80"
61
+ configs:
62
+ - source: caddyfile
63
+ target: /etc/caddy/Caddyfile
64
+ volumes:
65
+ - caddy-data:/data
66
+
67
+ backup:
68
+ image: docker.io/clickhouse/clickhouse-server:${CLICKHOUSE_TAG:-26.7.1.1315}
69
+ restart: unless-stopped
70
+ depends_on:
71
+ clickhouse: { condition: service_healthy }
72
+ entrypoint: ["/bin/bash", "/backup.sh"]
73
+ # The image declares a VOLUME at /var/lib/clickhouse; this service runs no server, and
74
+ # without the tmpfs every recreate would leave an anonymous volume behind.
75
+ tmpfs: [/var/lib/clickhouse]
76
+ environment:
77
+ CLICKHOUSE_USER: ${MEMHOUSE_ADMIN_USER:-admin}
78
+ CLICKHOUSE_PASSWORD: ${MEMHOUSE_ADMIN_PASSWORD:?set MEMHOUSE_ADMIN_PASSWORD in .env}
79
+ MEMHOUSE_DB: ${MEMHOUSE_DB:-mem}
80
+ BACKUP_AT: ${BACKUP_AT:-03:17}
81
+ BACKUP_KEEP: ${BACKUP_KEEP:-14}
82
+ BACKUP_ON_START: ${BACKUP_ON_START:-0}
83
+ configs:
84
+ - source: backup-script
85
+ target: /backup.sh
86
+ volumes:
87
+ - backups:/backups
88
+
89
+ configs:
90
+ clickhouse-memhouse:
91
+ content: |
92
+ <clickhouse>
93
+ <!-- memhouse's scoped shares (memhouse share USER with a scope) filter the grantee
94
+ with row policies. A reader WITHOUT a policy must keep seeing rows, or scoping one
95
+ person hides the house from everyone else; `memhouse share` measures this and
96
+ refuses when it is off. Set explicitly so an image default can never move it. -->
97
+ <access_control_improvements>
98
+ <users_without_row_policies_can_read_rows>true</users_without_row_policies_can_read_rows>
99
+ </access_control_improvements>
100
+ <!-- Where BACKUP ... TO Disk('backups', ...) writes. -->
101
+ <storage_configuration>
102
+ <disks>
103
+ <backups><type>local</type><path>/backups/</path></backups>
104
+ </disks>
105
+ </storage_configuration>
106
+ <backups>
107
+ <allowed_disk>backups</allowed_disk>
108
+ <allowed_path>/backups/</allowed_path>
109
+ </backups>
110
+ </clickhouse>
111
+
112
+ caddyfile:
113
+ content: |
114
+ {$$MEMHOUSE_DOMAIN} {
115
+ # `internal`: Caddy's own CA (testing, or a private network whose clients trust
116
+ # it). An e-mail address: a Let's Encrypt certificate for MEMHOUSE_DOMAIN.
117
+ tls {$$MEMHOUSE_TLS}
118
+ reverse_proxy clickhouse:8123
119
+ }
120
+
121
+ backup-script:
122
+ content: |
123
+ #!/bin/bash
124
+ # Nightly backup at BACKUP_AT (UTC), keeping the newest BACKUP_KEEP. One file holds
125
+ # the house AND the accounts: users, their grants and the row policies of scoped
126
+ # shares live outside the database, and a restore without them is a house nobody
127
+ # can log in to. README.md has the restore.
128
+ set -uo pipefail
129
+ ACCESS="TABLE system.users, TABLE system.roles, TABLE system.row_policies, TABLE system.settings_profiles, TABLE system.quotas"
130
+ run() {
131
+ if [ "$$(clickhouse-client --host clickhouse --query "EXISTS DATABASE $$MEMHOUSE_DB")" != "1" ]; then
132
+ echo "$$(date -u +%FT%TZ) no database '$$MEMHOUSE_DB' yet (no member has installed) — nothing to back up"
133
+ return
134
+ fi
135
+ name="$$MEMHOUSE_DB-$$(date -u +%Y-%m-%d-%H_%M).zip"
136
+ if clickhouse-client --host clickhouse --query \
137
+ "BACKUP DATABASE $$MEMHOUSE_DB, $$ACCESS TO Disk('backups', '$$name')" >/dev/null; then
138
+ echo "$$(date -u +%FT%TZ) backup written: $$name"
139
+ else
140
+ echo "$$(date -u +%FT%TZ) backup FAILED: $$name" >&2
141
+ fi
142
+ ls -1t /backups/"$$MEMHOUSE_DB"-*.zip 2>/dev/null | tail -n +"$$((BACKUP_KEEP + 1))" | while read -r old; do
143
+ rm -f -- "$$old" && echo "$$(date -u +%FT%TZ) pruned: $$(basename "$$old")"
144
+ done
145
+ }
146
+ [ "$$BACKUP_ON_START" = "1" ] && run
147
+ while true; do
148
+ now=$$(date -u +%s)
149
+ next=$$(date -u -d "today $$BACKUP_AT" +%s)
150
+ [ "$$next" -le "$$now" ] && next=$$(date -u -d "tomorrow $$BACKUP_AT" +%s)
151
+ sleep $$((next - now))
152
+ run
153
+ done
154
+
155
+ volumes:
156
+ data:
157
+ backups:
158
+ caddy-data: