The cheap "seam" that keeps the single-owner hub from calcifying, ahead of the gilbert -> indri bring-up: - Replace the single-tenant gate `Store::authorize_owner_sub(sub) -> bool` with `resolve_owner(sub) -> Option<owner_id>`. The hub auth middleware now resolves the token's identity to the owner it may act as (Some -> allow, None -> 403). Behavior is identical for the single-owner hub (claim-on-first; strangers still 403), but the contract no longer assumes one global owner, so serving N owners later is additive, not a rewrite. The per-request owner is marked at the exact line where downstream scoping wires through. - New how-to docs/how-to/set-up-sync-hub.md: stand up the hub and connect an existing device as an offline-capable spoke, the data-safe way (Path A: the hub adopts the device's identity rather than rewriting the device). The decision (cheap seam now, defer full multi-tenancy + adoption rewrite) is recorded in the Adoption + multi-tenant task's context doc. Two enabler gaps the how-to surfaced (heph daemon hub/spoke service flags; Path-A seeding tool) are filed as Hephaestus tasks. Green: 228 tests, clippy -D warnings + fmt + prek clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 KiB
| title | modified | tags | |
|---|---|---|---|
| Set up a sync hub (and connect a device) | 2026-06-04 |
|
Set up a sync hub (and connect a device)
How to stand up the canonical hub (on indri, in blumeops) and connect an
existing local device (e.g. gilbert) to it as an offline-capable spoke,
without migrating or risking the device's data.
The model
heph is hub-and-spoke, not a peer mesh (design §4, v1-prototype-tech-spec §3/§12/§13):
- Hub —
hephd --mode server: a full replica that also exposes an HTTP endpoint others sync against. One canonical hub (indri). - Spoke —
hephd --mode local --hub-url <hub>: its own full SQLite replica, fully usable offline, with an append-only op-log; it background-syncs (pull → merge → push) when the hub is reachable. Every device is a spoke.
Surfaces (CLI / TUI / nvim) only ever talk to the local daemon over the unix socket; that daemon handles the hub conversation in the background.
Transport vs. identity. Tailscale gives the devices a secure private network (reachability + encryption). Authentik sits on top as the authorization layer: the hub requires a valid OIDC bearer token on every op exchange, so merely being on the tailnet is not enough — this is the owner's most sensitive data.
The data-safety principle: the hub adopts the device, not the reverse
A device's owner_id is embedded in some node ids (journals, tags), the op-log,
and link rows. Rewriting it in place is the risky operation we avoid. Instead
("Path A"): the hub takes on the existing device's identity — same
owner_id and data — so the device is never rewritten. gilbert's store is
untouched; indri is brought up as a copy of it and the two sync forward.
A device that is set up after the hub exists skips all of this: configure it with the hub + Authentik from first launch ("born authed"), before it creates data, and it simply joins.
1. Authentik: register the heph application
Create an OIDC/OAuth2 application + provider in Authentik for heph, configured for the device-code (RFC 8628) flow. Note the values the daemon and devices need:
- Issuer — e.g.
https://authentik.ops.eblu.me/application/o/heph/ - Client id — the device-code client id (this is also the token audience).
2. Bring up the hub on indri
Seed it from gilbert (Path A). Quiesce gilbert (heph daemon stop),
copy its store to indri, and give indri its own device origin so the two
replicas don't share one (see Current gaps — this seeding step is the bit the
blumeops deployment finalizes). indri now holds gilbert's data under the same
owner_id.
Run the hub with auth enabled (issuer and audience together turn auth on; omit both only for local dev):
hephd --mode server \
--http-addr 0.0.0.0:8787 \
--db /var/lib/heph/heph.db \
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
--oidc-audience <heph-client-id>
The first identity to authenticate claims the hub's owner; thereafter only
that identity is served (single-owner today — see design and the
Adoption + multi-tenant task for the multi-tenancy seam).
3. Point gilbert at the hub (spoke)
Run gilbert's daemon in local mode with the hub url + its OIDC client id, then
log in once (the device-code flow caches a bearer token in the OS keyring):
hephd --mode local \
--hub-url http://indri.<tailnet>.ts.net:8787 \
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
--oidc-client-id <heph-client-id>
# one-time browser login on this device:
heph auth login \
--hub-url http://indri.<tailnet>.ts.net:8787 \
--issuer https://authentik.ops.eblu.me/application/o/heph/ \
--client-id <heph-client-id>
The spoke now attaches the (auto-refreshing) bearer token to every hub request and background-syncs on its interval.
4. Verify
heph sync --status # last push/pull cursors, hub url
heph sync # force a cycle now
Make a change on gilbert, force a sync, and confirm it appears via the hub.
Current gaps (finalized by the blumeops deployment)
The flag-level flow above works today; two enablers make it a clean, managed
deployment rather than a hand-run process — tracked in the Hephaestus project:
heph daemononly generates a--mode localservice (no--hub-url/--oidc-*). So for now the hub and the spoke config are expressed ashephdflags (run directly, or via the blumeops-managed systemd unit), not viaheph daemon start.- Path A seeding is manual (copy the store + reset the device origin). A
small enabler — seed a hub from a snapshot with a fresh origin, or
hephd --owner-id— would make this one step.
Related
- run-the-daemon — manage the local daemon as an OS service
- install-heph — install
heph/hephdand the plugin - design — §4 the connect-only, hub-and-spoke model
- v1-prototype-tech-spec — §3 runtime modes, §12 sync, §13 auth