feat(hephd): --owner-id flag — one-step Path-A hub seeding
All checks were successful
Build / validate (pull_request) Successful in 13m12s

A fresh hub started with an existing device's owner id rebuilds itself
entirely from that spoke's first full op-log push: no snapshot copy and
no origin reset (the new store mints its own). adopt_owner is idempotent
once adopted, so the flag is safe baked into a service unit.
[[set-up-sync-hub]] documents the recipe and drops the manual-seeding gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-09 10:55:17 -07:00
commit 8fe11c75cd
3 changed files with 84 additions and 17 deletions

View file

@ -84,6 +84,12 @@ struct Cli {
#[arg(long)]
oidc_client_id: Option<String>,
/// Adopt this canonical owner id at startup (idempotent once adopted).
/// Path-A hub seeding: a fresh hub started with an existing device's owner
/// id rebuilds from that spoke's first full op-log push — no snapshot copy.
#[arg(long)]
owner_id: Option<String>,
/// Opt-in (default off): periodically poll the forge for a newer release and
/// auto-update this daemon. Off unless this flag is given.
#[arg(long)]
@ -167,7 +173,12 @@ async fn main() -> Result<()> {
}
// Take the exclusive lock before opening the store (tech-spec §3.1).
let lock = LockGuard::acquire(&db)?;
let store = LocalStore::open(&db, Box::new(SystemClock))?;
let mut store = LocalStore::open(&db, Box::new(SystemClock))?;
if let Some(owner) = &cli.owner_id {
use heph_core::Store as _;
store.adopt_owner(owner)?;
tracing::info!(%owner, "adopted canonical owner id");
}
let spoke = cli.hub_url.as_deref().and_then(|hub| {
spoke_auth(hub, cli.oidc_issuer.as_ref(), cli.oidc_client_id.as_ref())
});

View file

@ -204,3 +204,54 @@ async fn divergent_scalar_edits_converge_through_the_hub_with_a_conflict() {
"B recorded no conflict"
);
}
#[tokio::test]
async fn fresh_hub_seeded_with_owner_id_rebuilds_from_first_sync() {
// Path-A seeding: a brand-new hub started with the device's owner id (the
// `hephd --owner-id` flag) needs no snapshot copy — the spoke's first sync
// replays its whole op-log into the hub, and the hub keeps its own fresh
// device origin by construction.
let http = reqwest::Client::new();
// The device: its own generated owner, with pre-existing data.
let dir = tempfile::tempdir().unwrap();
let mut device =
LocalStore::open(dir.path().join("heph.db"), Box::new(StepClock::new(1000))).unwrap();
let owner = device.owner_id().to_string();
let task = device
.create_task(NewTask {
title: "Pre-hub task".into(),
..Default::default()
})
.unwrap();
let device = Arc::new(Mutex::new(device));
// The hub: a fresh store adopting the device's owner (what --owner-id does).
let hub_dir = tempfile::tempdir().unwrap();
let mut hub_store = LocalStore::open(
hub_dir.path().join("heph.db"),
Box::new(StepClock::new(1000)),
)
.unwrap();
hub_store.adopt_owner(&owner).unwrap();
let hub = Arc::new(Mutex::new(hub_store));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = sync::router(hub.clone(), None);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let hub_url = format!("http://{addr}");
let report = sync::sync_once(device.clone(), &hub_url, &http, None)
.await
.unwrap();
assert!(report.pushed > 0, "device pushed nothing");
let on_hub = hub
.lock()
.unwrap()
.get_node(&task.node_id)
.unwrap()
.expect("task reached the hub");
assert_eq!(on_hub.title, "Pre-hub task");
}

View file

@ -73,23 +73,37 @@ avoid re-authenticating often, set generous validities on the heph provider:
## 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`.
**Seed it from `gilbert` (Path A) with `--owner-id`.** No snapshot copy: start
the hub on a **fresh, empty store** that adopts `gilbert`'s `owner_id`, and the
spoke's first sync replays its entire op-log into the hub (sync is op-based, so
a hub that shares the owner rebuilds completely from ops). The hub gets its own
device origin by construction — no origin-reset step, and `gilbert` is never
rewritten.
Run the hub with auth enabled (issuer **and** audience together turn auth on;
omit both only for local dev):
Find the device's owner id on `gilbert` (any node row carries it):
```bash
heph show "$(heph list --json | jq -r '.[0].node_id')" | grep owner_id
# or directly: sqlite3 ~/.local/share/heph/heph.db 'SELECT id FROM users'
```
Run the hub with that owner and 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 \
--owner-id <gilbert-owner-id> \
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
--oidc-audience <heph-client-id>
```
`--owner-id` is idempotent once adopted, so it is safe baked into the service
unit. (Copying the SQLite snapshot still works as a seeding shortcut for huge
stores, but then the copy shares `gilbert`'s device origin and must have it
reset — prefer the flag.)
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).
@ -150,15 +164,6 @@ The spoke then can't refresh on its own and needs a re-login — but this is
Run the printed `heph auth login …` command to restore sync.
## Current gaps (finalized by the blumeops deployment)
The flag-level flow above works today; one enabler makes it a clean, managed
deployment rather than a hand-run process — tracked in the `Hephaestus` project:
- **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.
> `heph daemon start`/`restart` can now bake the spoke/hub config (`--hub-url`,
> `--mode server`, `--http-addr`, `--oidc-*`) into the generated service (see
> [[run-the-daemon]]). The canonical hub on `indri` is still provisioned via the