generated from eblume/project-template
feat: multi-tenancy seam (resolve_owner) + hub-setup how-to (v1 prep)
All checks were successful
Build / validate (pull_request) Successful in 10m34s
All checks were successful
Build / validate (pull_request) Successful in 10m34s
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>
This commit is contained in:
parent
3f7012921b
commit
a0b04eefda
9 changed files with 172 additions and 25 deletions
1
docs/changelog.d/v1-hub-prep.doc.md
Normal file
1
docs/changelog.d/v1-hub-prep.doc.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
New how-to: [[set-up-sync-hub]] — stand up the canonical hub and connect an existing local device as an offline-capable spoke, the data-safe way (the hub adopts the device's identity rather than rewriting the device).
|
||||
1
docs/changelog.d/v1-hub-prep.infra.md
Normal file
1
docs/changelog.d/v1-hub-prep.infra.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Hub auth now **resolves an OIDC `sub` to an `owner_id`** (`Store::resolve_owner → Option<owner_id>`) instead of a single-tenant boolean gate (`authorize_owner_sub → bool`). Behavior is unchanged for the single-owner hub (claim-on-first; a stranger's token still 403s), but the contract no longer assumes one global owner — this is the multi-tenancy seam, so serving N owners later is additive rather than a rewrite. See the `Adoption + multi-tenant` task's context for the full decision.
|
||||
|
|
@ -18,4 +18,5 @@ Task-oriented guides for common operations.
|
|||
|
||||
- [[install-heph]] — Install `heph`/`hephd` from the forge, set up the Neovim plugin, and isolate in-repo development
|
||||
- [[run-the-daemon]] — Run `hephd` as an OS service with `heph daemon start/stop/restart/status`
|
||||
- [[set-up-sync-hub]] — Stand up the canonical hub (indri) and connect an existing device as an offline-capable spoke
|
||||
- [[import-todoist]] — Seed a heph store from your Todoist projects + tasks (`mise run import-todoist`)
|
||||
|
|
|
|||
125
docs/how-to/set-up-sync-hub.md
Normal file
125
docs/how-to/set-up-sync-hub.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
---
|
||||
title: Set up a sync hub (and connect a device)
|
||||
modified: 2026-06-04
|
||||
tags:
|
||||
- how-to
|
||||
---
|
||||
|
||||
# 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):
|
||||
|
||||
```bash
|
||||
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):
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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 daemon` only generates a `--mode local` service** (no `--hub-url` /
|
||||
`--oidc-*`). So for now the hub and the spoke config are expressed as `hephd`
|
||||
flags (run directly, or via the blumeops-managed systemd unit), not via
|
||||
`heph 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`/`hephd` and the plugin
|
||||
- [[design]] — §4 the connect-only, hub-and-spoke model
|
||||
- [[v1-prototype-tech-spec]] — §3 runtime modes, §12 sync, §13 auth
|
||||
Loading…
Add table
Add a link
Reference in a new issue