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.
- package/AGENTS.md +194 -0
- package/CHANGELOG.md +970 -0
- package/README.md +54 -423
- package/SECURITY.md +68 -0
- package/bin/memhouse.js +202 -92
- package/docs/README.md +46 -0
- package/docs/guides/admin-password.md +95 -0
- package/docs/guides/configuration.md +109 -0
- package/docs/guides/daemons.md +53 -0
- package/docs/guides/install.md +128 -0
- package/docs/guides/invite.md +87 -0
- package/docs/guides/operate.md +112 -0
- package/docs/guides/search-and-resume.md +95 -0
- package/docs/guides/share.md +102 -0
- package/docs/guides/storage.md +90 -0
- package/docs/guides/troubleshooting.md +78 -0
- package/docs/guides/uninstall.md +35 -0
- package/docs/guides/update.md +68 -0
- package/docs/releases/README.md +10 -0
- package/docs/releases/v0.18.10.md +226 -0
- package/docs/tutorials/01-first-house.md +132 -0
- package/docs/tutorials/02-join-a-house.md +91 -0
- package/docs/tutorials/03-share-your-memory.md +96 -0
- package/examples/01-local-one-machine/README.md +99 -0
- package/examples/01-local-one-machine/run.sh +61 -0
- package/examples/02-existing-clickhouse/README.md +121 -0
- package/examples/02-existing-clickhouse/run.sh +71 -0
- package/examples/03-team-house/README.md +129 -0
- package/examples/03-team-house/run.sh +124 -0
- package/examples/04-self-hosted-team/.env.example +22 -0
- package/examples/04-self-hosted-team/README.md +187 -0
- package/examples/04-self-hosted-team/compose.yml +158 -0
- package/examples/04-self-hosted-team/smoke.sh +113 -0
- package/examples/README.md +42 -0
- package/examples/fixtures/README.md +24 -0
- package/examples/fixtures/make-sessions.js +63 -0
- package/memhouse/admin-secret.js +81 -0
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/memhouse/deploy.js +4 -2
- package/memhouse/flags.js +4 -4
- package/memhouse/provision.js +12 -3
- package/memhouse/server/queries.js +18 -4
- package/memhouse/server/server.js +9 -1
- package/memhouse/service.js +5 -0
- 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:
|