clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

Delete the heartbeat; let iroh do the addressing

Dami committed dffe6c6
The daemon POSTed /api/daemon/heartbeat every 15 seconds carrying its endpoint
id, its relay url, and the resource ids it served. All three are gone.

iroh's presets::N0 — which both ends already use — installs a PkarrPublisher on
the daemon and a PkarrResolver on the viewer, and the resolver works in the
browser, over HTTPS to the n0 DNS server's /pkarr path. The daemon was already
publishing its own address and the browser could already resolve it. The
with_relay_url() in connection_to was handing iroh something it would have found
by itself.

Taking the three fields in turn:

  relay_url    redundant, as above.

  endpoint_id  the public half of the key at DEVSITE_HOME/identity. Identical on
               every run, so it is registered once when the daemon starts, via
               PUT /api/daemon.

  serving,     the online/offline dot, and a pre-flight refusal in
  last_seen    /api/capability. The refusal was never a security property: the
               daemon checks resource-in-local-config on every request anyway,
               so dropping the control-plane copy makes nothing more permissive.
               The dot was stale by up to 45 seconds, and M6 records it lying.

So presence is gone rather than reduced. A profile makes no claim about
reachability and clicking a service finds out. ViewerEndpoint::fetch now takes an
address — a bare endpoint id in the browser, resolved by iroh — and bounds the
attempt with CONNECT_TIMEOUT. "Offline" means we asked and nobody answered, which
is the only version of it that was ever true.

The cost, stated plainly: a dead daemon takes CONNECT_TIMEOUT to report instead
of being greyed out in advance. Everything else is upside. Steady-state writes go
to zero, so the database is read-mostly and a single SQLite connection behind one
mutex stops being the thing that bounds concurrent users. There is no timer on
the user's machine. And the control plane is out of the addressing path, which is
what it always claimed to be.

The authz tests keep passing an explicit relay. They are about what the daemon
enforces, not about how it was found, and routing them through a third-party
lookup would turn a denial into something that could also be a slow resolve. The
discovery path was verified in a real browser instead: a service opened with only
an endpoint id in the capability response, then the daemon was killed and the
timeout and its message were confirmed.

Migration 3 drops relay_url, serving and last_seen. SCHEMA is left alone, as the
append-only rule requires, so a fresh database creates those columns and then
drops them — and a database old enough to still hold a heartbeat's worth of data
keeps its daemon's identity across the change. There is a test for that.

68 tests.
commit
dffe6c6b379889be26aed1ed735804d514222af1
parent
6a8ca6bd7104fc41c58fa5e995e424cd66d79313
author
Dami <dami.etoile@gmail.com>
16 files changed, +263 -272
MCargo.lock1+
MREADME.md10++-
Mcrates/devsite-cli/src/client.rs10+-
Mcrates/devsite-cli/src/main.rs45+++------
Mcrates/devsite-client/Cargo.toml3+
Mcrates/devsite-client/src/lib.rs49++++++----
Mcrates/devsite-daemon/src/config.rs3+-
Mcrates/devsite-daemon/tests/authz.rs20++--
Mcrates/devsite-server/src/api.rs110++++++++---------------
Mcrates/devsite-server/src/db.rs148++++++++++++------------------
Mcrates/devsite-server/src/theme.rs2+-
Mcrates/devsite-web/src/lib.rs14+-
Mdocs/milestones.md43+++++++++-
Mdocs/profile-template.md16++-
Mweb/app.js50++++++----
Mweb/site.css11+--
M Cargo.lock +1 -0
M README.md +9 -1
M crates/devsite-cli/src/client.rs +5 -5
M crates/devsite-cli/src/main.rs +15 -30
@@ -191,7 +191,7 @@ async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()>
191191 192192 let api = ControlPlane::new(server, Some(token.clone()));193193 // Confirm the token works before storing it, so a typo fails here rather than at the194- // first heartbeat.194+ // first API call.195195 let me: serde_json::Value = api196196 .get("/api/me")197197 .await
@@ -397,14 +397,6 @@ async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {
397397 let server_url = config.server_url.clone().unwrap_or_else(|| server.to_string());398398 let token = config.session_token.clone();399399 400- // Reported on every heartbeat so the control plane can tell "daemon is up" from401- // "daemon still serves this", and never advertise an exposure that has been removed.402- let serving: Vec<String> = config403- .resources404- .iter()405- .map(|r| r.resource_id.to_string())406- .collect();407-408400 let secret_key = paths.load_or_create_identity()?;409401 let daemon = Arc::new(Daemon::bind(secret_key, config).await?);410402
@@ -421,32 +413,25 @@ async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {
421413 println!(" endpoint {endpoint_id}");422414 println!(" relay {relay}");423415 424- // Heartbeat loop: presence in the UI, and the address browsers are handed.416+ // Told to the control plane once, not on a timer.417+ //418+ // The endpoint id is the public half of the key in DEVSITE_HOME/identity, so419+ // it is the same on every run and there is nothing to refresh. The relay420+ // above is printed for the operator and deliberately not uploaded: the421+ // endpoint publishes its own address through iroh's address lookup, and422+ // viewers resolve it from there. The control plane holds permissions; it is423+ // not a directory service.425424 let api = ControlPlane::new(&server_url, token);426- let heartbeat = tokio::spawn(async move {427- let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));428- loop {429- ticker.tick().await;430- if let Err(err) = api431- .post_empty(432- "/api/daemon/heartbeat",433- &serde_json::json!({434- "endpoint_id": endpoint_id,435- "relay_url": relay,436- "serving": serving,437- }),438- )439- .await440- {441- tracing::warn!("heartbeat failed: {err:#}");442- }443- }444- });425+ api.put_empty(426+ "/api/daemon",427+ &serde_json::json!({ "endpoint_id": endpoint_id }),428+ )429+ .await430+ .context("registering this daemon with the control plane")?;445431 446432 tokio::select! {447433 result = Arc::clone(&daemon).serve() => result?,448434 _ = tokio::signal::ctrl_c() => println!("\nshutting down"),449435 }450- heartbeat.abort();451436 Ok(())452437 }
M crates/devsite-client/Cargo.toml +3 -0
M crates/devsite-client/src/lib.rs +31 -18
@@ -7,13 +7,23 @@
77 88 use std::collections::HashMap;99 use std::sync::Mutex;10+use std::time::Duration;1011 1112 use anyhow::{Context, Result};1213 use devsite_proto::capability::SignedCapability;1314 use devsite_proto::wire::{self, ErrorCode, HttpRequest, Method, Request, Response};1415 use devsite_proto::ALPN;1516 use iroh::endpoint::Connection;16-use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl};17+use iroh::{Endpoint, EndpointAddr, EndpointId};18+use n0_future::time::timeout;19+20+/// How long to wait for a daemon before giving up on it.21+///22+/// There is no presence service telling us in advance whether the far end is23+/// running, so this is what "offline" means now: we asked, and nobody answered.24+/// Long enough to cover address lookup and a relay handshake, short enough that25+/// a dead daemon does not leave someone watching a spinner.26+pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);1727 1828 /// Why a fetch did not produce a page.1929 ///
@@ -88,25 +98,31 @@ impl ViewerEndpoint {
8898 self.endpoint.id()8999 }90100 91- /// Fetch a page from a daemon over the relay, presenting `capability`.101+ /// Fetch a page from a daemon, presenting `capability`.102+ ///103+ /// `daemon` is normally a bare [`EndpointId`]: the daemon publishes its own104+ /// address through iroh's address lookup, and the browser resolves it over105+ /// HTTPS. Nothing has to tell us where it is. Callers that already know an106+ /// address — the tests, which want a relay pinned rather than a lookup — can107+ /// pass a fuller [`EndpointAddr`].92108 ///93109 /// The capability must have been issued for this endpoint's key; the daemon checks94110 /// that binding against the connection and will refuse otherwise.95111 pub async fn fetch(96112 &self,97- daemon: EndpointId,98- relay: RelayUrl,113+ daemon: impl Into<EndpointAddr>,99114 capability: SignedCapability,100115 path: &str,101116 ) -> Result<FetchedPage, FetchError> {102- let connection = self.connection_to(daemon, &relay).await?;117+ let addr = daemon.into();118+ let connection = self.connection_to(&addr).await?;103119 match request_page(&connection, capability.clone(), path).await {104120 Err(FetchError::Transport(err)) => {105121 // The cached connection may simply have aged out. Drop it and try once106122 // more on a fresh one before reporting failure.107123 tracing::debug!("retrying on a fresh connection: {err:#}");108- self.connections.lock().unwrap().remove(&daemon);109- let connection = self.connection_to(daemon, &relay).await?;124+ self.connections.lock().unwrap().remove(&addr.id);125+ let connection = self.connection_to(&addr).await?;110126 request_page(&connection, capability, path).await111127 }112128 other => other,
@@ -114,31 +130,28 @@ impl ViewerEndpoint {
114130 }115131 116132 /// A live connection to `daemon`, reusing the cached one when there is one.117- async fn connection_to(118- &self,119- daemon: EndpointId,120- relay: &RelayUrl,121- ) -> Result<Connection, FetchError> {133+ async fn connection_to(&self, daemon: &EndpointAddr) -> Result<Connection, FetchError> {122134 // Scoped so the guard is released before the await below. An `if let` scrutinee123135 // temporary would otherwise live to the end of the whole statement, and the124136 // second lock in this function would deadlock against it.125137 let existing = {126138 let cache = self.connections.lock().unwrap();127- cache.get(&daemon).cloned()139+ cache.get(&daemon.id).cloned()128140 };129141 if let Some(existing) = existing {130142 return Ok(existing);131143 }132- let addr = EndpointAddr::from(daemon).with_relay_url(relay.clone());133- let connection = self134- .endpoint135- .connect(addr, ALPN)144+ // Bounded explicitly. Without a presence service there is nothing to tell145+ // us the far end is down before we try, so an unreachable daemon has to146+ // surface as a timeout rather than as a connect that waits on the relay.147+ let connection = timeout(CONNECT_TIMEOUT, self.endpoint.connect(daemon.clone(), ALPN))136148 .await149+ .map_err(|_| anyhow::anyhow!("no answer from the daemon"))?137150 .context("connecting to daemon")?;138151 self.connections139152 .lock()140153 .unwrap()141- .insert(daemon, connection.clone());154+ .insert(daemon.id, connection.clone());142155 Ok(connection)143156 }144157 }
M crates/devsite-daemon/src/config.rs +2 -1
M crates/devsite-daemon/tests/authz.rs +11 -9
M crates/devsite-server/src/api.rs +37 -73
@@ -14,7 +14,7 @@ use devsite_proto::{AccountId, ResourceId};
1414 use serde::{Deserialize, Serialize};1515 1616 use crate::auth::{self, ShooVerifier};17-use crate::db::{Db, DaemonPresence, ResourceKind};17+use crate::db::{Db, ResourceKind};1818 use crate::issuer::Issuer;1919 use crate::policy::{can_view, Visibility};2020 use crate::theme;
@@ -144,13 +144,8 @@ pub struct CreateResourceResponse {
144144 }145145 146146 #[derive(Deserialize)]147-pub struct HeartbeatRequest {147+pub struct RegisterDaemonRequest {148148 pub endpoint_id: String,149- pub relay_url: String,150- /// The resources this daemon is currently configured to serve. A resource missing from151- /// this list is reported offline even while the daemon itself is healthy.152- #[serde(default)]153- pub serving: Vec<String>,154149 }155150 156151 #[derive(Serialize)]
@@ -160,8 +155,6 @@ pub struct ProfileEntry {
160155 pub kind: &'static str,161156 pub visibility: &'static str,162157 pub url: Option<String>,163- /// `None` for links, which have no daemon behind them.164- pub online: Option<bool>,165158 /// Present only on "shared with me" entries.166159 #[serde(skip_serializing_if = "Option::is_none")]167160 pub owner_handle: Option<String>,
@@ -202,8 +195,9 @@ pub struct CapabilityRequest {
202195 pub struct CapabilityResponse {203196 /// base64url of the postcard-encoded signed capability.204197 pub capability: String,198+ /// Who to present it to. Not where they are — the browser resolves that199+ /// through iroh's address lookup, from the daemon's own published record.205200 pub daemon_endpoint_id: String,206- pub relay_url: String,207201 }208202 209203 // -- routes --------------------------------------------------------------------
@@ -216,7 +210,7 @@ pub fn router(state: Shared) -> Router {
216210 .route("/api/me", get(me))217211 .route("/api/profile", post(claim_handle))218212 .route("/api/resources", post(create_resource).get(list_resources))219- .route("/api/daemon/heartbeat", post(heartbeat))213+ .route("/api/daemon", axum::routing::put(register_daemon))220214 .route("/api/theme", get(read_theme).put(write_theme))221215 .route("/api/theme/properties", get(theme_properties))222216 .route("/api/profile/{handle}", get(profile))
@@ -399,33 +393,27 @@ async fn list_resources(
399393 })))400394 }401395 402-async fn heartbeat(396+/// Record which endpoint id this account's daemon answers on.397+///398+/// Called once when a daemon starts, not on a timer. The id is stable across399+/// restarts, and where that endpoint can be reached is published by the daemon400+/// itself and resolved by the browser — the control plane is not in the401+/// addressing path and does not want to be.402+async fn register_daemon(403403 State(state): State<Shared>,404404 headers: HeaderMap,405- Json(body): Json<HeartbeatRequest>,405+ Json(body): Json<RegisterDaemonRequest>,406406 ) -> ApiResult<impl IntoResponse> {407407 let account = require_account(&state, &headers)?;408- // Validate before storing so a malformed relay url cannot poison later reads.409- url::Url::parse(&body.relay_url).map_err(|_| ApiError::bad_request("relay_url is not a url"))?;408+ // Validated before storing: a malformed id would be handed to browsers as409+ // something to go looking for, and fail somewhere much less obvious.410410 if parse_endpoint_id(&body.endpoint_id).is_none() {411411 return Err(ApiError::bad_request("endpoint_id is not a 32 byte hex key"));412412 }413413 414- let serving: Vec<ResourceId> = body415- .serving416- .iter()417- .filter_map(|id| ResourceId::from_str(id).ok())418- .collect();419-420414 let db = state.db.lock().unwrap();421- db.record_heartbeat(422- account,423- &body.endpoint_id,424- &body.relay_url,425- &serving,426- now_secs(),427- )428- .map_err(ApiError::internal)?;415+ db.register_daemon(account, &body.endpoint_id)416+ .map_err(ApiError::internal)?;429417 Ok(StatusCode::NO_CONTENT)430418 }431419
@@ -486,7 +474,6 @@ async fn profile(
486474 ) -> ApiResult<impl IntoResponse> {487475 let viewer = current_account(&state, &headers)?;488476 let handle = handle.trim_start_matches('@').to_ascii_lowercase();489- let now = now_secs();490477 491478 let db = state.db.lock().unwrap();492479 let owner = db
@@ -494,8 +481,6 @@ async fn profile(
494481 .map_err(ApiError::internal)?495482 .ok_or_else(ApiError::not_found)?;496483 497- let presence = db.daemon(owner.id).map_err(ApiError::internal)?;498-499484 let mut entries = Vec::new();500485 for resource in db.resources_owned_by(owner.id).map_err(ApiError::internal)? {501486 let shared_with = db.shared_with(resource.id).map_err(ApiError::internal)?;
@@ -504,10 +489,7 @@ async fn profile(
504489 if !can_view(viewer, owner.id, resource.visibility, &shared_with) {505490 continue;506491 }507- let reachable = presence508- .as_ref()509- .is_some_and(|p| p.is_serving(resource.id, now));510- entries.push(to_entry(&resource, Some(reachable), None));492+ entries.push(to_entry(&resource, None));511493 }512494 513495 // "Shared with me" belongs on your own profile, not on everyone else's. Listing it on
@@ -525,15 +507,7 @@ async fn profile(
525507 let owner_account = db526508 .account_by_id(resource.owner_id)527509 .map_err(ApiError::internal)?;528- let reachable = db529- .daemon(resource.owner_id)530- .map_err(ApiError::internal)?531- .is_some_and(|p| p.is_serving(resource.id, now));532- shared_with_me.push(to_entry(533- &resource,534- Some(reachable),535- owner_account.and_then(|a| a.handle),536- ));510+ shared_with_me.push(to_entry(&resource, owner_account.and_then(|a| a.handle)));537511 }538512 }539513
@@ -558,21 +532,13 @@ async fn profile(
558532 }))559533 }560534 561-fn to_entry(562- resource: &crate::db::Resource,563- online: Option<bool>,564- owner_handle: Option<String>,565-) -> ProfileEntry {535+fn to_entry(resource: &crate::db::Resource, owner_handle: Option<String>) -> ProfileEntry {566536 ProfileEntry {567537 resource_id: resource.id.to_string(),568538 name: resource.name.clone(),569539 kind: resource.kind.as_str(),570540 visibility: resource.visibility.as_str(),571541 url: resource.url.clone(),572- online: match resource.kind {573- ResourceKind::Link => None,574- ResourceKind::Service => Some(online.unwrap_or(false)),575- },576542 owner_handle,577543 }578544 }
@@ -592,7 +558,7 @@ async fn capability(
592558 let browser_key =593559 parse_endpoint_id(&body.browser_endpoint_id).ok_or_else(|| ApiError::bad_request("browser_endpoint_id is not a 32 byte hex key"))?;594560 595- let (resource, presence) = {561+ let (resource, endpoint_id) = {596562 let db = state.db.lock().unwrap();597563 let resource = db598564 .resource(resource_id)
@@ -611,36 +577,34 @@ async fn capability(
611577 if resource.kind != ResourceKind::Service {612578 return Err(ApiError::bad_request("that resource is a link, not a service"));613579 }614- let presence = db.daemon(resource.owner_id).map_err(ApiError::internal)?;615- (resource, presence)580+ let endpoint_id = db581+ .daemon_endpoint(resource.owner_id)582+ .map_err(ApiError::internal)?;583+ (resource, endpoint_id)616584 };617585 618- let presence: DaemonPresence = presence.ok_or_else(|| {619- ApiError(StatusCode::SERVICE_UNAVAILABLE, "that service is offline".into())620- })?;621- let now = now_secs();622- // Not merely "is the daemon alive" — is it still exposing *this* resource. Issuing a623- // capability for something the daemon has stopped serving just produces a confusing624- // denial at the far end instead of an honest answer here.625- if !presence.is_serving(resource.id, now) {626- return Err(ApiError(586+ // The only thing that can be answered from here. Whether the daemon is587+ // *running* is not knowable at this end without asking it, so the browser588+ // asks it directly and reports what it finds. This case is the narrower one:589+ // no daemon has ever registered, so there is not even an address to try.590+ let endpoint_id = endpoint_id.ok_or_else(|| {591+ ApiError(627592 StatusCode::SERVICE_UNAVAILABLE,628- "that service is offline".into(),629- ));630- }593+ "no daemon has been registered for that service — run `devsite daemon run`".into(),594+ )595+ })?;631596 632- let audience = parse_endpoint_id(&presence.endpoint_id)597+ let audience = parse_endpoint_id(&endpoint_id)633598 .ok_or_else(|| ApiError::internal(anyhow::anyhow!("stored daemon endpoint id is invalid")))?;634599 635600 let capability = state636601 .issuer637- .issue(viewer, resource.id, audience, browser_key, now)602+ .issue(viewer, resource.id, audience, browser_key, now_secs())638603 .map_err(ApiError::internal)?;639604 640605 Ok(Json(CapabilityResponse {641606 capability: data_encoding::BASE64URL_NOPAD.encode(&capability.to_bytes()),642- daemon_endpoint_id: presence.endpoint_id,643- relay_url: presence.relay_url.to_string(),607+ daemon_endpoint_id: endpoint_id,644608 }))645609 }646610
M crates/devsite-server/src/db.rs +58 -90
@@ -8,7 +8,6 @@ use std::str::FromStr;
88 use anyhow::{Context, Result};99 use devsite_proto::{AccountId, ResourceId};1010 use rusqlite::{params, Connection, OptionalExtension};11-use url::Url;1211 1312 use crate::policy::Visibility;1413
@@ -75,6 +74,15 @@ const MIGRATIONS: &[&str] = &[
7574 // JSON array of the resource ids a daemon is actually configured to serve, so a7675 // resource can be reported reachable only if its owner's daemon still exposes it.7776 "ALTER TABLE daemons ADD COLUMN serving TEXT NOT NULL DEFAULT '[]';",77+ // Presence is gone, and with it the 15-second heartbeat that maintained these78+ // three columns. A daemon's endpoint id is derived from its secret key and79+ // never changes, so it is registered once; its address is published by the80+ // daemon itself through iroh's address lookup and resolved by the browser,81+ // which is why the control plane no longer stores a relay url. What remains82+ // is the identity a capability is addressed to.83+ "ALTER TABLE daemons DROP COLUMN relay_url;84+ ALTER TABLE daemons DROP COLUMN serving;85+ ALTER TABLE daemons DROP COLUMN last_seen;",7886 ];7987 8088 fn migrate(conn: &Connection) -> Result<()> {
@@ -87,9 +95,6 @@ fn migrate(conn: &Connection) -> Result<()> {
8795 Ok(())8896 }8997 90-/// A daemon is considered online if it has checked in this recently.91-pub const PRESENCE_WINDOW_SECS: u64 = 45;92-9398 pub struct Db {9499 conn: Connection,95100 }
@@ -133,26 +138,6 @@ impl ResourceKind {
133138 }134139 }135140 136-#[derive(Debug, Clone)]137-pub struct DaemonPresence {138- pub endpoint_id: String,139- pub relay_url: Url,140- pub serving: Vec<ResourceId>,141- pub last_seen: u64,142-}143-144-impl DaemonPresence {145- /// Whether the daemon itself has checked in recently.146- pub fn is_online(&self, now: u64) -> bool {147- now.saturating_sub(self.last_seen) <= PRESENCE_WINDOW_SECS148- }149-150- /// Whether a specific resource is actually reachable right now.151- pub fn is_serving(&self, resource: ResourceId, now: u64) -> bool {152- self.is_online(now) && self.serving.contains(&resource)153- }154-}155-156141 impl Db {157142 pub fn open(path: &str) -> Result<Self> {158143 let conn = Connection::open(path).with_context(|| format!("opening database {path}"))?;
@@ -415,61 +400,33 @@ impl Db {
415400 Ok(())416401 }417402 418- // -- daemon presence --------------------------------------------------------403+ // -- daemon identity --------------------------------------------------------419404 420- pub fn record_heartbeat(421- &self,422- account: AccountId,423- endpoint_id: &str,424- relay_url: &str,425- serving: &[ResourceId],426- now: u64,427- ) -> Result<()> {428- let serving = serde_json::to_string(429- &serving.iter().map(ToString::to_string).collect::<Vec<_>>(),430- )?;405+ /// Record which endpoint id an account's daemon answers on.406+ ///407+ /// Written once, when a daemon starts, rather than on a timer: the id is the408+ /// public half of the key at `DEVSITE_HOME/identity`, so it survives restarts409+ /// and only changes if that file does. It says nothing about whether the410+ /// daemon is running — only who to address a capability to, and who the411+ /// browser should go looking for.412+ pub fn register_daemon(&self, account: AccountId, endpoint_id: &str) -> Result<()> {431413 self.conn.execute(432- "INSERT INTO daemons (account_id, endpoint_id, relay_url, serving, last_seen)433- VALUES (?1, ?2, ?3, ?4, ?5)434- ON CONFLICT(account_id) DO UPDATE SET435- endpoint_id = excluded.endpoint_id,436- relay_url = excluded.relay_url,437- serving = excluded.serving,438- last_seen = excluded.last_seen",439- params![account.to_string(), endpoint_id, relay_url, serving, now as i64],414+ "INSERT INTO daemons (account_id, endpoint_id) VALUES (?1, ?2)415+ ON CONFLICT(account_id) DO UPDATE SET endpoint_id = excluded.endpoint_id",416+ params![account.to_string(), endpoint_id],440417 )?;441418 Ok(())442419 }443420 444- pub fn daemon(&self, account: AccountId) -> Result<Option<DaemonPresence>> {421+ pub fn daemon_endpoint(&self, account: AccountId) -> Result<Option<String>> {445422 self.conn446423 .query_row(447- "SELECT endpoint_id, relay_url, serving, last_seen FROM daemons448- WHERE account_id = ?1",424+ "SELECT endpoint_id FROM daemons WHERE account_id = ?1",449425 params![account.to_string()],450- |row| {451- Ok((452- row.get::<_, String>(0)?,453- row.get::<_, String>(1)?,454- row.get::<_, String>(2)?,455- row.get::<_, i64>(3)?,456- ))457- },426+ |row| row.get::<_, String>(0),458427 )459- .optional()?460- .map(|(endpoint_id, relay_url, serving, last_seen)| {461- let serving: Vec<String> = serde_json::from_str(&serving).unwrap_or_default();462- Ok(DaemonPresence {463- endpoint_id,464- relay_url: Url::parse(&relay_url)?,465- serving: serving466- .iter()467- .filter_map(|id| ResourceId::from_str(id).ok())468- .collect(),469- last_seen: last_seen as u64,470- })471- })472- .transpose()428+ .optional()429+ .map_err(Into::into)473430 }474431 }475432
@@ -558,23 +515,18 @@ mod tests {
558515 }559516 560517 #[test]561- fn presence_lapses_after_the_window() {518+ fn registering_a_daemon_is_idempotent_and_survives_re_registration() {562519 let (db, dami, _) = seeded();563- let served = ResourceId::generate();564- let dropped = ResourceId::generate();565- db.record_heartbeat(dami.id, "abc", "https://relay.example/", &[served], 1000)566- .unwrap();567- let presence = db.daemon(dami.id).unwrap().unwrap();520+ assert!(db.daemon_endpoint(dami.id).unwrap().is_none());568521 569- assert!(presence.is_online(1000));570- assert!(presence.is_online(1000 + PRESENCE_WINDOW_SECS));571- assert!(!presence.is_online(1001 + PRESENCE_WINDOW_SECS));522+ db.register_daemon(dami.id, "abc").unwrap();523+ db.register_daemon(dami.id, "abc").unwrap();524+ assert_eq!(db.daemon_endpoint(dami.id).unwrap().as_deref(), Some("abc"));572525 573- // Reachability is per resource, not merely per daemon: a live daemon that no574- // longer exposes something must not have it advertised as reachable.575- assert!(presence.is_serving(served, 1000));576- assert!(!presence.is_serving(dropped, 1000));577- assert!(!presence.is_serving(served, 1001 + PRESENCE_WINDOW_SECS));526+ // A new identity file means a new endpoint id, and the account should527+ // follow it rather than keeping an id nothing answers on.528+ db.register_daemon(dami.id, "def").unwrap();529+ assert_eq!(db.daemon_endpoint(dami.id).unwrap().as_deref(), Some("def"));578530 }579531 580532 #[test]
@@ -582,6 +534,10 @@ mod tests {
582534 // Simulates the real failure: a database built from an older schema, then opened583535 // by a newer binary. Without migrations the new column is silently absent and584536 // every read of it fails at runtime.537+ //538+ // It now covers the other direction too. Removing presence dropped three539+ // columns, and a database old enough to still have them — with a540+ // heartbeat's worth of data in them — has to survive being opened.585541 let file = std::env::temp_dir().join(format!("devsite-migrate-{}.db", std::process::id()));586542 std::fs::remove_file(&file).ok();587543
@@ -594,17 +550,29 @@ mod tests {
594550 0,595551 "the legacy database should look unmigrated"596552 );553+ let legacy_account = AccountId::generate().to_string();554+ legacy555+ .execute(556+ "INSERT INTO accounts (id, external_sub, handle, created_at)557+ VALUES (?1, 'ps_x', 'dami', 0)",558+ params![legacy_account],559+ )560+ .unwrap();561+ legacy562+ .execute(563+ "INSERT INTO daemons (account_id, endpoint_id, relay_url, last_seen)564+ VALUES (?1, 'abc', 'https://relay.example/', 100)",565+ params![legacy_account],566+ )567+ .unwrap();597568 drop(legacy);598569 599570 let db = Db::open(file.to_str().unwrap()).unwrap();600- let dami = db.upsert_account("ps_x", 0).unwrap();601- db.set_handle(dami.id, "dami").unwrap();602- let resource = ResourceId::generate();603- db.record_heartbeat(dami.id, "abc", "https://relay.example/", &[resource], 100)604- .unwrap();571+ let dami = db.account_by_handle("dami").unwrap().unwrap();605572 606- let presence = db.daemon(dami.id).unwrap().unwrap();607- assert!(presence.is_serving(resource, 100));573+ // The identity survives the columns around it being dropped: an existing574+ // daemon keeps working without re-registering.575+ assert_eq!(db.daemon_endpoint(dami.id).unwrap().as_deref(), Some("abc"));608576 609577 std::fs::remove_file(&file).ok();610578 }
M crates/devsite-server/src/theme.rs +1 -1
M crates/devsite-web/src/lib.rs +7 -7
M docs/milestones.md +42 -1
@@ -22,7 +22,8 @@ Findings:
2222 ## M2 — Control plane ✅2323 2424 Accounts, sessions, profiles, resource registration, sharing, daemon heartbeat/presence,25-and profile rendering, all filtered through one `can_view` choke point.25+and profile rendering, all filtered through one `can_view` choke point. (The heartbeat and26+presence half of that was removed in M8 — see below.)2627 2728 Auth is **Shoo** (`https://shoo.dev`) — an OIDC broker that is **Google-backed, not2829 GitHub**, despite the initial assumption. Real OIDC: authorize + PKCE S256 in the browser,
@@ -139,6 +140,46 @@ website, `devsite theme properties` and the docs cannot drift from what is accep
139140 140141 See `docs/profile-template.md`.141142 143+## M8 — No heartbeat ✅144+145+The daemon used to POST `/api/daemon/heartbeat` every 15 seconds, carrying its endpoint id,146+its relay url, and the resource ids it served. All three are gone.147+148+Reading iroh's `presets::N0` settles it. The preset installs a `PkarrPublisher` on the149+daemon and a `PkarrResolver` on the viewer, and the resolver works **in the browser**, over150+HTTPS to the n0 DNS server's `/pkarr` path — plain DNS is added only outside browsers. The151+daemon was already publishing its own address and the browser could already resolve it. The152+`with_relay_url()` in `connection_to` was feeding iroh something it would have found itself.153+154+Taking the three fields in turn:155+156+- **`relay_url`** was redundant, as above.157+- **`endpoint_id`** is the public half of the key at `DEVSITE_HOME/identity`. It is the158+ same on every run, so it is registered once when the daemon starts. `PUT /api/daemon`.159+- **`serving` and `last_seen`** existed for the online/offline dot and a pre-flight refusal160+ in `/api/capability`. The refusal was never a security property — the daemon checks161+ resource-in-local-config on every request regardless, so removing the control-plane check162+ makes nothing more permissive. And the dot was stale by up to 45 seconds; M6 records it163+ lying outright.164+165+So presence is gone entirely. A profile makes no claim about reachability, and clicking a166+service finds out: `ViewerEndpoint::fetch` takes a bare endpoint id, lets iroh resolve it,167+and bounds the attempt with `CONNECT_TIMEOUT`. "Offline" now means "we asked and nobody168+answered", which is the only thing that was ever true.169+170+What this bought:171+172+- **Steady-state writes go to zero.** The database is read-mostly; the write path is a173+ handful of rows per user per lifetime rather than 5,760 per daemon per day.174+- **No timer on the user's machine**, and no 15-second wakeups against a laptop's radio.175+- **The control plane is out of the addressing path**, which is what it always claimed.176+177+The cost is honest and worth naming: a dead daemon takes `CONNECT_TIMEOUT` to report rather178+than being greyed out in advance.179+180+Verified in a browser against a real daemon: reached with only an endpoint id and no relay181+url anywhere in the response, then killed the daemon and got the timeout and its message.182+142183 ## Next143184 144185 Surfaces that still have none: managing exposures from the website, and seeing who a
M docs/profile-template.md +9 -7
M web/app.js +30 -20
@@ -164,25 +164,27 @@ function renderSession() {
164164 : '<li><small>no handle yet</small></li>';165165 }166166 167-/// One row of a profile: a name on the left, its state on the right.167+/// One row of a profile.168168 ///169169 /// Links are anchors and services are buttons, because a link goes somewhere and170-/// a service opens here. Nothing about that changes with the theme.171-function entry({ kind, name, state, online, href, onClick }) {170+/// a service opens here.171+///172+/// A service carries no reachability state. Nothing on this page knows whether173+/// the far end is running — the control plane is not told, and the only thing174+/// that could answer is a connection attempt. So the row makes no claim, and175+/// clicking it finds out.176+function entry({ kind, name, state, href, onClick }) {172177 const li = document.createElement('li');173178 li.className = 'entry';174179 li.dataset.kind = kind;175- if (online !== undefined) li.dataset.state = online ? 'online' : 'offline';176180 177181 if (href) {178182 li.innerHTML =179183 `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${name}</a>` +180184 `<small class="state">${esc(state)}</small>`;181185 } else {182- li.innerHTML =183- `<button class="outline${online ? '' : ' secondary'}"${online ? '' : ' disabled'}>${name}</button>` +184- `<small class="state">${esc(state)}</small>`;185- if (online && onClick) li.querySelector('button').addEventListener('click', onClick);186+ li.innerHTML = `<button class="outline">${name}</button>`;187+ if (onClick) li.querySelector('button').addEventListener('click', onClick);186188 }187189 return li;188190 }
@@ -233,8 +235,6 @@ function renderProfile(profile) {
233235 buckets[item.visibility].push(entry({234236 kind: 'service',235237 name: esc(item.name),236- state: item.online ? 'online' : 'offline',237- online: item.online,238238 onClick: () => openService(item),239239 }));240240 }
@@ -251,8 +251,6 @@ function renderProfile(profile) {
251251 const rows = profile.shared_with_me.map((item) => entry({252252 kind: 'service',253253 name: `${esc(item.name)} <small>from @${esc(item.owner_handle)}</small>`,254- state: item.online ? 'online' : 'offline',255- online: item.online,256254 onClick: () => openService(item),257255 }));258256 article.append(group('Shared with me', 'shared-with-me', rows));
@@ -391,13 +389,11 @@ async function openService(item) {
391389 });392390 393391 paintHops(2);394- status.textContent = 'Connecting through the relay';395- const html = await endpoint.fetchPage(396- grant.daemon_endpoint_id,397- grant.relay_url,398- grant.capability,399- '/',400- );392+ status.textContent = 'Looking up the daemon and connecting';393+ // The daemon is named, not located: `fetchPage` takes an endpoint id and lets394+ // iroh resolve where it currently is. This is also the step that discovers395+ // whether it is running at all, and it gives up after a few seconds.396+ const html = await endpoint.fetchPage(grant.daemon_endpoint_id, grant.capability, '/');401397 402398 paintHops(3);403399 mountFrame(html);
@@ -406,10 +402,24 @@ async function openService(item) {
406402 } catch (err) {407403 status.removeAttribute('aria-busy');408404 status.setAttribute('aria-invalid', 'true');409- status.textContent = String(err.message || err);405+ status.innerHTML = unreachable(err)406+ ? `<span class="error">Couldn't reach ${esc(item.name)}.</span> ` +407+ `Nothing answered on that machine — is <code>devsite daemon run</code> running?`408+ : `<span class="error">${esc(String(err.message || err))}</span>`;410409 }411410 }412411 412+/// Whether a failure means "nobody answered" rather than "they said no".413+///414+/// Worth distinguishing in the message: a refusal is a decision the daemon made415+/// and the viewer cannot fix, while silence is nearly always a daemon that is416+/// not running.417+function unreachable(err) {418+ const message = String(err?.message || err);419+ return message.includes('no answer from the daemon')420+ || message.includes('connecting to daemon');421+}422+413423 // -- setup views ---------------------------------------------------------------414424 415425 function renderSignedOut() {
M web/site.css +2 -9