diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 2a11ef1..ebd01af 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -423,7 +423,7 @@ impl Store for LocalStore { syncstate::record(&self.conn, peer, pushed, pulled, now) } - fn authorize_owner_sub(&mut self, sub: &str) -> Result { + fn resolve_owner(&mut self, sub: &str) -> Result> { // The owner's bound identity (NULL until first authenticated sync). let current: Option = self .conn @@ -436,13 +436,15 @@ impl Store for LocalStore { .flatten(); match current { None => { + // Claim-on-first: bind this sub to the store's owner. self.conn.execute( "UPDATE users SET oidc_sub = ?1 WHERE id = ?2", (sub, &self.owner_id), )?; - Ok(true) + Ok(Some(self.owner_id.clone())) } - Some(existing) => Ok(existing == sub), + Some(existing) if existing == sub => Ok(Some(self.owner_id.clone())), + Some(_) => Ok(None), } } diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index cb0475d..3fd8275 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -216,12 +216,18 @@ pub trait Store { fn record_sync(&mut self, peer: &str, pushed: Option<&str>, pulled: Option<&str>) -> Result<()>; - /// Single-tenant authentication gate (tech-spec §13). Map an OIDC `sub` to - /// this store's owner: on first sight, **claim** the owner by binding its - /// `oidc_sub`; thereafter authorize only that same `sub`. Returns `true` if - /// the `sub` owns this store, `false` if a different identity presented a - /// token. A hub calls this before serving any op exchange. - fn authorize_owner_sub(&mut self, sub: &str) -> Result; + /// Resolve an OIDC `sub` to the `owner_id` it may act as on this store — + /// the **multi-tenancy seam** (tech-spec §13). On first sight, **claim** the + /// store's owner by binding its `oidc_sub`; thereafter resolve only that + /// same `sub`. Returns `Some(owner_id)` when the `sub` owns data here, or + /// `None` when a different identity presented a token (the hub then 403s). + /// + /// Today a store hosts exactly one owner, so this resolves to that single + /// owner or `None`. Multi-tenancy (serving N owners from one hub) extends + /// this to a real `sub → owner_id` mapping with per-`sub` provisioning, and + /// the hub scopes each request to the resolved owner — without changing this + /// contract. A hub calls this before serving any op exchange. + fn resolve_owner(&mut self, sub: &str) -> Result>; /// Open merge conflicts surfaced for the user (`heph conflicts`). fn conflicts_list(&self) -> Result>; diff --git a/crates/heph-core/tests/convergence.rs b/crates/heph-core/tests/convergence.rs index ea5d8fb..9f9608e 100644 --- a/crates/heph-core/tests/convergence.rs +++ b/crates/heph-core/tests/convergence.rs @@ -75,18 +75,26 @@ fn sync_cursors_default_empty_then_advance_per_direction() { } #[test] -fn owner_sub_gate_claims_first_then_requires_match() { - // Single-tenant gate (§13): the first sub claims the owner; only that sub - // is authorized thereafter. +fn resolve_owner_claims_first_then_requires_match() { + // The hub resolves an OIDC `sub` to its owner id (§13) — the multi-tenancy + // seam. The first sub claims the (single, for now) owner; only that sub + // resolves thereafter, and always to the same owner id. let (mut a, _ca) = replica(1000); - assert!(a.authorize_owner_sub("sub-alice").unwrap(), "first claims"); - assert!(a.authorize_owner_sub("sub-alice").unwrap(), "same sub ok"); + let owner = a.resolve_owner("sub-alice").unwrap().expect("first claims"); + assert_eq!( + a.resolve_owner("sub-alice").unwrap().as_deref(), + Some(owner.as_str()), + "same sub resolves to the same owner" + ); assert!( - !a.authorize_owner_sub("sub-mallory").unwrap(), - "a different identity must be rejected" + a.resolve_owner("sub-mallory").unwrap().is_none(), + "a different identity does not resolve (the hub then 403s)" ); // Still bound to the original after a rejection. - assert!(a.authorize_owner_sub("sub-alice").unwrap()); + assert_eq!( + a.resolve_owner("sub-alice").unwrap().as_deref(), + Some(owner.as_str()) + ); } #[test] diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 9f405a8..c486055 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -315,10 +315,10 @@ impl Store for RemoteStore { Ok(()) } - fn authorize_owner_sub(&mut self, _sub: &str) -> Result { - // Hub-side gate; a no-replica client never hosts an endpoint to guard. + fn resolve_owner(&mut self, _sub: &str) -> Result> { + // Hub-side seam; a no-replica client never hosts an endpoint to guard. Err(Error::Remote( - "authorize_owner_sub is a hub-side operation".into(), + "resolve_owner is a hub-side operation".into(), )) } diff --git a/crates/hephd/src/sync.rs b/crates/hephd/src/sync.rs index 4080907..266cae1 100644 --- a/crates/hephd/src/sync.rs +++ b/crates/hephd/src/sync.rs @@ -136,20 +136,23 @@ async fn require_auth( _ => StatusCode::UNAUTHORIZED, })?; - // Single-tenant gate: the token's identity must own this hub. + // Multi-tenancy seam: resolve the token's identity to the owner it may act + // as. Today the hub serves one owner, so this is `Some(that owner)` or + // `None` (→ 403). When the hub becomes multi-owner, `_owner_id` is what each + // downstream handler scopes its ops to (rather than the store's lone owner). let store = state.store.clone(); - let owns = tokio::task::spawn_blocking(move || { + let owner = tokio::task::spawn_blocking(move || { store .lock() .expect("store mutex poisoned") - .authorize_owner_sub(&claims.sub) + .resolve_owner(&claims.sub) }) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if !owns { + let Some(_owner_id) = owner else { return Err(StatusCode::FORBIDDEN); - } + }; Ok(next.run(request).await) } diff --git a/docs/changelog.d/v1-hub-prep.doc.md b/docs/changelog.d/v1-hub-prep.doc.md new file mode 100644 index 0000000..08c92f0 --- /dev/null +++ b/docs/changelog.d/v1-hub-prep.doc.md @@ -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). diff --git a/docs/changelog.d/v1-hub-prep.infra.md b/docs/changelog.d/v1-hub-prep.infra.md new file mode 100644 index 0000000..e2a9ad4 --- /dev/null +++ b/docs/changelog.d/v1-hub-prep.infra.md @@ -0,0 +1 @@ +Hub auth now **resolves an OIDC `sub` to an `owner_id`** (`Store::resolve_owner → Option`) 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. diff --git a/docs/how-to/how-to.md b/docs/how-to/how-to.md index 265cd37..9a3a758 100644 --- a/docs/how-to/how-to.md +++ b/docs/how-to/how-to.md @@ -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`) diff --git a/docs/how-to/set-up-sync-hub.md b/docs/how-to/set-up-sync-hub.md new file mode 100644 index 0000000..a0f7706 --- /dev/null +++ b/docs/how-to/set-up-sync-hub.md @@ -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 `: 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 +``` + +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..ts.net:8787 \ + --oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \ + --oidc-client-id + +# one-time browser login on this device: +heph auth login \ + --hub-url http://indri..ts.net:8787 \ + --issuer https://authentik.ops.eblu.me/application/o/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