diff --git a/CHANGELOG.md b/CHANGELOG.md index 232a649d..1bbb6e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Added +- Sync notification read progress across signed-in sessions with a private, attested SpacetimeDB watermark. The existing local cache works offline; a delayed background connection uses separate credentials without creating chat presence or DM keys. - Videos up to 250 MB upload in 8 MB chunks. Retry continues from the last acknowledged chunk; Remove cancels the pending upload. Preparation and finishing have visible states, and the cast stays disabled until its attachments are ready. - Compatible MP4 and MOV videos are prepared for HLS from bounded metadata and file slices, preserving their frames and sound without copying the entire video into WebAssembly. Existing image limits and storage safeguards remain. diff --git a/crates/allthing-core/src/lib.rs b/crates/allthing-core/src/lib.rs index 8fa1080d..e4024af4 100644 --- a/crates/allthing-core/src/lib.rs +++ b/crates/allthing-core/src/lib.rs @@ -32,6 +32,7 @@ pub mod message; pub mod metadata; pub mod model; pub mod normalize; +pub mod notification_state; pub mod notifications; pub mod params; pub mod people; diff --git a/crates/allthing-core/src/notification_state.rs b/crates/allthing-core/src/notification_state.rs new file mode 100644 index 00000000..4d102c6f --- /dev/null +++ b/crates/allthing-core/src/notification_state.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! Monotonic notification read state. Local progress works offline; only an +//! acknowledged write or the caller's private subscription advances `remote`. + +/// A corrupt cache or provider clock cannot mark arbitrary future events read. +pub fn valid_seen(at: i64, now: i64) -> i64 { + if at < 0 || at > now.saturating_add(60) { 0 } else { at.min(now.max(0)) } +} + +#[derive(Clone, Debug, Default)] +pub struct ReadState { + seen: i64, + remote: i64, + baseline: Option, +} + +impl ReadState { + pub fn new(local: i64, now: i64) -> Self { + Self { seen: valid_seen(local, now), ..Self::default() } + } + + pub fn seen(&self) -> i64 { + self.seen + } + + pub fn baseline(&self) -> Option { + self.baseline + } + + pub fn observe_local(&mut self, at: i64, now: i64) { + self.seen = self.seen.max(valid_seen(at, now)); + } + + pub fn observe_remote(&mut self, at: i64, now: i64) { + self.remote = self.remote.max(valid_seen(at, now)); + self.seen = self.seen.max(self.remote); + } + + /// Capture the prior server state before uploading this visit's pending + /// reads. It lets a cold notification page correct its initial row tint. + pub fn hydrated(&mut self) { + self.baseline.get_or_insert(self.remote); + } + + pub fn pending(&self) -> Option { + (self.baseline.is_some() && self.seen > self.remote).then_some(self.seen) + } + + pub fn acknowledged(&mut self, at: i64) { + self.remote = self.remote.max(at.min(self.seen)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merges_offline_progress_without_overwriting_a_newer_session() { + let mut state = ReadState::new(100, 1000); + assert_eq!(state.pending(), None); + state.observe_local(300, 1000); + state.observe_remote(200, 1000); + state.hydrated(); + assert_eq!(state.baseline(), Some(200)); + assert_eq!(state.pending(), Some(300)); + state.observe_remote(400, 1000); + state.acknowledged(300); + state.observe_remote(50, 1000); + assert_eq!(state.seen(), 400); + assert_eq!(state.pending(), None); + assert_eq!(state.baseline(), Some(200)); + } + + #[test] + fn failed_writes_keep_progress_and_an_old_ack_cannot_clear_a_new_read() { + let mut state = ReadState::new(100, 1000); + state.hydrated(); + let sent = state.pending().unwrap(); + assert_eq!(state.pending(), Some(sent)); // no acknowledgement on failure + state.observe_local(500, 1000); + state.acknowledged(sent); + assert_eq!(state.pending(), Some(500)); + state.acknowledged(500); + assert_eq!(state.pending(), None); + } + + #[test] + fn delayed_initial_snapshot_does_not_include_this_visits_pending_mark() { + let mut state = ReadState::new(0, 1000); + state.observe_local(900, 1000); + state.observe_remote(700, 1000); + state.hydrated(); + assert_eq!(state.baseline(), Some(700)); + assert_eq!(state.pending(), Some(900)); + state.observe_remote(950, 1000); + state.hydrated(); + assert_eq!(state.baseline(), Some(700)); + } + + #[test] + fn untrusted_timestamps_cannot_poison_a_valid_marker() { + for at in [-1, i64::MIN, i64::MAX, 1061] { + assert_eq!(valid_seen(at, 1000), 0); + } + assert_eq!(valid_seen(1060, 1000), 1000); + let mut state = ReadState::new(i64::MAX, 1000); + state.observe_remote(900, 1000); + state.observe_local(i64::MAX, 1000); + assert_eq!(state.seen(), 900); + } +} diff --git a/crates/allthing-web/src/components/notifications.rs b/crates/allthing-web/src/components/notifications.rs index 5434a360..bdfc5c91 100644 --- a/crates/allthing-web/src/components/notifications.rs +++ b/crates/allthing-web/src/components/notifications.rs @@ -4,8 +4,8 @@ //! app starts, every three minutes while the page is in view, and when it //! is back in view or its window regains focus (a read within a minute is //! reused: `api`), and the newest time the reader has seen, which this -//! browser keeps by account (`allthing-notifications-seen` in localStorage; -//! the server keeps none). The header's bell counts the groups newer than +//! browser caches by account (`allthing-notifications-seen` in localStorage) +//! and SpacetimeDB shares with the account's other verified sessions. The header's bell counts the groups newer than //! that, and the notifications page (`pages::notifications`), which reads //! the whole payload from the same answer, marks them seen. Not in the //! static copy, which has no sign-in. @@ -14,6 +14,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; +use allthing_core::notification_state::valid_seen; use allthing_core::notifications::{NOTIFICATIONS_PATH, Times, badge, bell_label}; use leptos::prelude::*; use leptos::task::spawn_local; @@ -47,6 +48,10 @@ struct State { loading: bool, /// The newest time `whose` has seen (Unix seconds), 0 for never. seen: i64, + /// Fences in-flight reads even if A signs out and signs back in as A. + generation: u64, + /// Prior remote state, captured before this visit's pending read is sent. + remote_baseline: Option, } fn storage() -> Option { @@ -60,7 +65,21 @@ fn kept() -> BTreeMap { } fn seen_of(fid: u64) -> i64 { - kept().get(&fid.to_string()).copied().unwrap_or(0) + valid_seen(kept().get(&fid.to_string()).copied().unwrap_or(0), now()) +} + +fn now() -> i64 { + (js_sys::Date::now() / 1000.0) as i64 +} + +fn keep_seen(fid: u64, at: i64) -> i64 { + let at = valid_seen(at, now()).max(seen_of(fid)); + let mut all = kept(); + all.insert(fid.to_string(), at); + if let (Some(storage), Ok(text)) = (storage(), serde_json::to_string(&all)) { + let _ = storage.set_item(SEEN_KEY, &text); + } + at } impl Notifications { @@ -76,8 +95,10 @@ impl Notifications { if notifications.whose() != fid { api::forget(NOTIFICATIONS_PATH); api::forget(allthing_core::following::FOLLOWING_PATH); - notifications.0.set(State { whose: fid, seen: fid.map_or(0, seen_of), ..State::default() }); + let generation = notifications.generation().wrapping_add(1); + notifications.0.set(State { whose: fid, seen: fid.map_or(0, seen_of), generation, ..State::default() }); } + crate::notification_sync::account(fid, notifications); if fid.is_some() { notifications.refresh(); } @@ -94,16 +115,22 @@ impl Notifications { let woken = move |_| { if !document().hidden() { notifications.refresh(); + crate::notification_sync::wake(); } }; let _ = window_event_listener_untyped("visibilitychange", woken); let _ = window_event_listener_untyped("focus", woken); + let _ = window_event_listener_untyped("online", move |_| crate::notification_sync::wake()); // Seen in another of this browser's tabs. let _ = window_event_listener(leptos::ev::storage, move |ev| { if ev.key().as_deref() == Some(SEEN_KEY) && let Some(fid) = notifications.whose() { - notifications.0.update(|state| state.seen = seen_of(fid)); + let seen = seen_of(fid).max(notifications.seen_by(fid)); + if seen > notifications.seen_by(fid) { + notifications.0.update(|state| state.seen = seen); + } + crate::notification_sync::seen(fid, seen); } }); notifications @@ -119,6 +146,34 @@ impl Notifications { self.whose() == Some(fid) } + pub fn generation(self) -> u64 { + self.0.with_untracked(|state| state.generation) + } + + pub fn same_session(self, fid: u64, generation: u64) -> bool { + self.are_for(fid) && self.generation() == generation + } + + pub fn sync_baseline(self, fid: u64) -> Option { + self.0.with(|state| if state.whose == Some(fid) { state.remote_baseline } else { None }) + } + + /// A private subscription has advanced. Do not echo it as another write. + pub fn synced(self, fid: u64, seen: i64, baseline: Option) { + if !self.are_for(fid) { + return; + } + let seen = keep_seen(fid, seen).max(self.seen_by(fid)); + if self.0.with_untracked(|s| seen > s.seen || (s.remote_baseline.is_none() && baseline.is_some())) { + self.0.update(|state| { + state.seen = seen; + if state.remote_baseline.is_none() { + state.remote_baseline = baseline; + } + }); + } + } + /// The first page's times once its latest refresh succeeded (tracked). /// Old times must not start a page read behind a refresh that can fail. pub fn times(self) -> Option> { @@ -152,6 +207,7 @@ impl Notifications { /// within a minute can be reused or is on its way (`api::get`). pub fn refresh(self) { let Some(fid) = self.0.with_untracked(|state| state.whose.filter(|_| !state.loading)) else { return }; + let generation = self.generation(); // Coalesce before spawning: a second caller otherwise waits in // api::get for the first read. A throttled tab can resume that // waiter after the cache has expired and start a redundant read. @@ -159,7 +215,7 @@ impl Notifications { spawn_local(async move { let read = api::get::(NOTIFICATIONS_PATH).await; // Signed out, or someone else, meanwhile. - if self.whose() != Some(fid) { + if !self.same_session(fid, generation) { return; } self.0.update(|state| { @@ -186,15 +242,13 @@ impl Notifications { /// `fid` has seen everything up to `newest` (Unix seconds): the bell /// clears, here and in the browser's other tabs. pub fn mark_seen(self, fid: u64, newest: i64) { + let newest = valid_seen(newest, now()); if !self.are_for(fid) || newest <= self.seen_by(fid) { return; } + let newest = keep_seen(fid, newest); self.0.update(|state| state.seen = newest); - let mut all = kept(); - all.insert(fid.to_string(), newest); - if let (Some(storage), Ok(text)) = (storage(), serde_json::to_string(&all)) { - let _ = storage.set_item(SEEN_KEY, &text); - } + crate::notification_sync::seen(fid, newest); } /// How many groups of the first page the reader hasn't seen (tracked). diff --git a/crates/allthing-web/src/keys.rs b/crates/allthing-web/src/keys.rs index 16af1562..812db15d 100644 --- a/crates/allthing-web/src/keys.rs +++ b/crates/allthing-web/src/keys.rs @@ -33,6 +33,8 @@ struct Database { const APP_KEYS: Database = Database { name: "allthing-keys", store: "keys" }; /// SpacetimeDB identities and message keys. const REALTIME: Database = Database { name: "allthing-stdb", store: "identities" }; +/// Notification sync tokens have no message keys and cannot overwrite them. +const NOTIFICATION_SYNC: Database = Database { name: "allthing-notification-sync", store: "identities" }; /// The account a key made to sign in with is kept for, until the account /// that approves it is known. pub const SIGNING_IN: u64 = 0; @@ -175,6 +177,34 @@ pub struct Realtime { pub key: Option<(CryptoKey, [u8; 32])>, } +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub struct NotificationIdentity { + pub fid: u64, + pub identity: String, + pub token: String, +} + +pub async fn notification_identity(fid: u64) -> Result, KeyError> { + let found = + request(NOTIFICATION_SYNC, IdbTransactionMode::Readonly, |store| store.get(&JsValue::from_f64(fid as f64))) + .await?; + let Some(identity) = field(&found, "identity").as_string() else { return Ok(None) }; + let Some(token) = field(&found, "token").as_string() else { return Ok(None) }; + Ok(Some(NotificationIdentity { fid, identity, token })) +} + +pub async fn keep_notification_identity(kept: &NotificationIdentity) -> Result<(), KeyError> { + let record = Object::new(); + for (name, value) in [ + ("fid", JsValue::from_f64(kept.fid as f64)), + ("identity", JsValue::from_str(&kept.identity)), + ("token", JsValue::from_str(&kept.token)), + ] { + Reflect::set(&record, &name.into(), &value).map_err(failed)?; + } + request(NOTIFICATION_SYNC, IdbTransactionMode::Readwrite, |store| store.put(&record)).await.map(|_| ()) +} + /// What this browser keeps for `fid`'s Messages, if anything. pub async fn realtime(fid: u64) -> Result, KeyError> { let found = diff --git a/crates/allthing-web/src/lib.rs b/crates/allthing-web/src/lib.rs index 8d89416e..817f2227 100644 --- a/crates/allthing-web/src/lib.rs +++ b/crates/allthing-web/src/lib.rs @@ -15,11 +15,15 @@ mod keys; mod leanings; mod miniapps; mod nav; +#[cfg(not(feature = "static-host"))] +mod notification_sync; mod pages; mod preload; mod press; mod seen; mod split; +#[cfg(not(feature = "static-host"))] +mod stdb_bridge; mod util; mod video; diff --git a/crates/allthing-web/src/notification_sync.rs b/crates/allthing-web/src/notification_sync.rs new file mode 100644 index 00000000..ccfae413 --- /dev/null +++ b/crates/allthing-web/src/notification_sync.rs @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! A private, account-wide notification watermark. This connection never +//! subscribes to chat, publishes message keys or changes public presence. + +use std::cell::{Cell, RefCell}; +use std::sync::Arc; +use std::time::Duration; + +use allthing_core::notification_state::ReadState; +use allthing_core::stdb::{AttestRequest, Attestation, Called, STDB_DATABASE}; +use js_sys::{Array, Promise, Reflect}; +use leptos::prelude::*; +use send_wrapper::SendWrapper; +use wasm_bindgen::{JsValue, closure::Closure}; + +use crate::api; +use crate::components::notifications::Notifications; +use crate::keys::{self, NotificationIdentity}; +use crate::stdb_bridge::{await_promise, clear_timeout, js_error, load_bridge, method, parse, set_timeout, uri}; + +const BRIDGE: Option<&str> = option_env!("ALLTHING_STDB_BRIDGE"); +const QUERY: &str = "[\"SELECT * FROM my_notification_state\"]"; +type Emit = Closure; + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; +} + +#[derive(Clone)] +struct Sync { + fid: u64, + notifications: Notifications, + inner: Arc>, +} + +struct Inner { + not_before: f64, + stopped: Cell, + connecting: Cell, + disconnected: Cell, + ready: Cell, + writing: Cell, + attempt: Cell, + failures: Cell, + state: RefCell, + credentials: RefCell>, + bridge: RefCell>, + emit: RefCell>, + timer: RefCell>, +} + +fn now() -> i64 { + (js_sys::Date::now() / 1000.0) as i64 +} + +fn visible_online() -> bool { + web_sys::window().is_some_and(|w| w.navigator().on_line()) && !crate::util::document().hidden() +} + +/// Called by the app's account effect, including sign-out. A stopped +/// instance fences every asynchronous completion, even after A → B → A. +pub fn account(fid: Option, notifications: Notifications) { + if CURRENT.with(|s| s.borrow().as_ref().map(|s| s.fid)) == fid { + return; + } + if let Some(old) = CURRENT.with(|s| s.borrow_mut().take()) { + old.stop(); + } + let Some(fid) = fid else { return }; + let sync = Sync { + fid, + notifications, + inner: Arc::new(SendWrapper::new(Inner { + not_before: js_sys::Date::now() + 2000.0, + stopped: Cell::new(false), + connecting: Cell::new(false), + disconnected: Cell::new(false), + ready: Cell::new(false), + writing: Cell::new(false), + attempt: Cell::new(0), + failures: Cell::new(0), + state: RefCell::new(ReadState::new(notifications.seen_by(fid), now())), + credentials: RefCell::new(None), + bridge: RefCell::new(None), + emit: RefCell::new(None), + timer: RefCell::new(None), + })), + }; + CURRENT.with(|s| *s.borrow_mut() = Some(sync.clone())); + sync.schedule(2000); +} + +/// A local read or another tab's cache update. The acknowledged server +/// watermark is kept separate, so a failed write never loses its retry. +pub fn seen(fid: u64, at: i64) { + if let Some(sync) = CURRENT.with(|s| s.borrow().clone()).filter(|s| s.fid == fid) { + sync.inner.state.borrow_mut().observe_local(at, now()); + if sync.inner.ready.get() && !sync.inner.writing.get() { + sync.schedule(200); + } + } +} + +pub fn wake() { + if let Some(sync) = CURRENT.with(|s| s.borrow().clone()) { + if sync.inner.ready.get() { + sync.flush(); + } else if !sync.inner.connecting.get() { + sync.schedule(0); + } + } +} + +impl Sync { + fn active(&self) -> bool { + !self.inner.stopped.get() && self.notifications.are_for(self.fid) + } + + fn clear_timer(&self) { + if let Some(timer) = self.inner.timer.borrow_mut().take() { + timer.clear(); + } + } + + fn schedule(&self, millis: u64) { + self.clear_timer(); + if !self.active() { + return; + } + let sync = self.clone(); + let timer = set_timeout_with_handle( + move || { + sync.inner.timer.borrow_mut().take(); + if !sync.active() || !visible_online() { + return; + } + if sync.inner.ready.get() { + sync.flush(); + } else if !api::quiet() { + sync.schedule(250); + } else { + crate::components::cast::idle(move || sync.connect()); + } + }, + Duration::from_millis(millis.max((self.inner.not_before - js_sys::Date::now()).max(0.0).ceil() as u64)), + ); + *self.inner.timer.borrow_mut() = timer.ok(); + } + + fn detach(&self) { + if let Some(bridge) = self.inner.bridge.borrow_mut().take() + && let Ok(disconnect) = method(&bridge, "disconnect") + { + let _ = disconnect.call0(&bridge); + } + // Bridge.disconnect releases this callback before closing its socket. + self.inner.emit.borrow_mut().take(); + } + + fn stop(&self) { + self.inner.stopped.set(true); + self.inner.ready.set(false); + self.clear_timer(); + self.detach(); + } + + fn retry(&self) { + self.inner.ready.set(false); + if !self.active() || self.inner.connecting.get() || self.inner.writing.get() { + return; + } + self.detach(); + let failures = self.inner.failures.get().saturating_add(1); + self.inner.failures.set(failures); + let delay = [5000, 15000, 60000, 180000, 300000][failures.saturating_sub(1).min(4) as usize]; + self.schedule(delay); + } + + fn connect(&self) { + if !self.active() || !visible_online() || self.inner.connecting.replace(true) { + return; + } + self.inner.disconnected.set(false); + self.inner.attempt.set(self.inner.attempt.get().wrapping_add(1)); + let sync = self.clone(); + leptos::task::spawn_local(async move { + let result = sync.start().await; + sync.inner.connecting.set(false); + if !sync.active() { + sync.detach(); + return; + } + if result.is_err() { + sync.retry(); + return; + } + sync.inner.failures.set(0); + sync.inner.ready.set(true); + sync.flush(); + }); + } + + fn check(&self) -> Result<(), String> { + if self.active() && !self.inner.disconnected.get() { + Ok(()) + } else { + Err("Session changed or disconnected".into()) + } + } + + async fn start(&self) -> Result<(), String> { + // The existing status response advertises the pinned attestation + // service. Unconfigured servers/fixtures never load the SDK or dial. + #[derive(serde::Deserialize)] + struct Status { + spacetime: Capability, + } + #[derive(serde::Deserialize)] + struct Capability { + attest: bool, + } + let status = api::get::("/api/v1/status").await.map_err(|e| e.message())?; + self.check()?; + if !status.data.spacetime.attest { + return Err("Notification sync is not configured".into()); + } + if self.inner.credentials.borrow().is_none() { + let stored = keys::notification_identity(self.fid).await.ok().flatten(); + self.check()?; + *self.inner.credentials.borrow_mut() = stored; + } + let global = load_bridge(BRIDGE.ok_or("Notification sync is not part of this build")?).await?; + self.check()?; + let weak = Arc::downgrade(&self.inner); + let notifications = self.notifications; + let fid = self.fid; + let attempt = self.inner.attempt.get(); + let emit = Closure::::new(move |json: String| { + let Some(inner) = weak.upgrade() else { return }; + let sync = Sync { fid, notifications, inner }; + if sync.active() && sync.inner.attempt.get() == attempt { + sync.event(&json); + } + }); + let token = self.inner.credentials.borrow().as_ref().map(|s| s.token.clone()).unwrap_or_default(); + let bridge = method(&global, "connect")? + .apply(&global, &Array::of4(&uri().into(), &STDB_DATABASE.into(), &token.into(), emit.as_ref())) + .map_err(js_error)?; + *self.inner.bridge.borrow_mut() = Some(bridge.clone()); + *self.inner.emit.borrow_mut() = Some(emit); + #[derive(serde::Deserialize)] + struct Connected { + identity: String, + token: String, + } + let connected: Connected = + parse(bounded(Reflect::get(&bridge, &"connected".into()).map_err(js_error)?).await?)?; + self.check()?; + if !allthing_core::stdb::is_identity(&connected.identity) { + return Err("Invalid notification identity".into()); + } + let stored = + NotificationIdentity { fid: self.fid, identity: connected.identity.clone(), token: connected.token }; + *self.inner.credentials.borrow_mut() = Some(stored.clone()); + let _ = keys::keep_notification_identity(&stored).await; // memory still works when storage is denied + self.check()?; + let att = + api::post::<_, Attestation>("/api/v1/auth/attest", &AttestRequest { identity: connected.identity.clone() }) + .await + .map_err(|e| e.message())?; + self.check()?; + if att.fid != self.fid || att.identity != connected.identity { + return Err("The signed-in account changed".into()); + } + self.call( + "verifyNotificationFid", + &[(att.fid as f64).into(), (att.expiry as f64).into(), att.signature.into()], + ) + .await?; + self.check()?; + bounded(self.raw("subscribe", &[QUERY.into()])?).await?; + self.check()?; + self.inner.state.borrow_mut().hydrated(); + let state = self.inner.state.borrow(); + self.notifications.synced(self.fid, state.seen(), state.baseline()); + Ok(()) + } + + fn raw(&self, name: &str, args: &[JsValue]) -> Result { + self.check()?; + let bridge = self.inner.bridge.borrow().clone().ok_or("Not connected")?; + method(&bridge, name)?.apply(&bridge, &args.iter().collect::()).map_err(js_error) + } + + async fn call(&self, name: &str, args: &[JsValue]) -> Result<(), String> { + parse::(bounded(self.raw(name, args)?).await?)?.result() + } + + fn event(&self, json: &str) { + #[derive(serde::Deserialize)] + struct Event { + t: String, + #[serde(default)] + table: String, + row: Option, + } + #[derive(serde::Deserialize)] + struct Row { + fid: u64, + seen_at: i64, + } + let Ok(event) = serde_json::from_str::(json) else { return }; + if event.t == "disconnected" { + self.inner.disconnected.set(true); + self.retry(); + } else if event.table == "my_notification_state" + && matches!(event.t.as_str(), "insert" | "update") + && let Some(row) = event.row.filter(|r| r.fid == self.fid) + { + let mut state = self.inner.state.borrow_mut(); + state.observe_remote(row.seen_at, now()); + self.notifications.synced(self.fid, state.seen(), state.baseline()); + } + } + + fn flush(&self) { + if !self.active() || !self.inner.ready.get() || self.inner.writing.replace(true) { + return; + } + let sync = self.clone(); + leptos::task::spawn_local(async move { + while sync.active() && sync.inner.ready.get() { + let Some(at) = sync.inner.state.borrow().pending() else { break }; + if sync.call("markNotificationsSeen", &[(at as f64).into()]).await.is_err() { + sync.inner.writing.set(false); + sync.retry(); + return; + } + if !sync.active() { + break; + } + sync.inner.state.borrow_mut().acknowledged(at); + } + sync.inner.writing.set(false); + if sync.active() && !sync.inner.ready.get() { + sync.retry(); + } + }); + } +} + +/// A stalled connection/subscription/reducer cannot hold a retry forever. +async fn bounded(value: JsValue) -> Result { + let timer = RefCell::new(None); + let timeout = Promise::new(&mut |_, reject| { + let reject = reject.bind1(&JsValue::NULL, &"Notification sync timed out".into()); + *timer.borrow_mut() = set_timeout(reject.as_ref(), 30_000).ok(); + }); + let answer = await_promise(Promise::race(&Array::of2(&Promise::resolve(&value), &timeout)).into()).await; + if let Some(timer) = timer.borrow_mut().take() { + clear_timeout(&timer); + } + answer +} diff --git a/crates/allthing-web/src/pages/messages/realtime/live.rs b/crates/allthing-web/src/pages/messages/realtime/live.rs index 32414b26..363fff59 100644 --- a/crates/allthing-web/src/pages/messages/realtime/live.rs +++ b/crates/allthing-web/src/pages/messages/realtime/live.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later //! The connection to Allthing's SpacetimeDB database, through the bridge -//! (web/stdb/bridge.ts), which this adds to the page as a module script the -//! first time a signed-in reader opens Messages: nothing else fetches it. +//! (web/stdb/bridge.ts), added once by the shared loader when a signed-in +//! feature needs it. //! //! Signed in as `fid`, it connects with the token this browser keeps for //! the account (`keys::realtime`), or none the first time, and keeps the @@ -15,19 +15,17 @@ use std::cell::RefCell; use std::collections::BTreeMap; use std::sync::Arc; -use allthing_core::stdb::{ - AttestRequest, Attestation, Called, Event, Mirror, STDB_DATABASE, STDB_URI, hex, subscriptions, -}; -use js_sys::{Function, Promise, Reflect}; +use allthing_core::stdb::{AttestRequest, Attestation, Called, Event, Mirror, STDB_DATABASE, hex, subscriptions}; +use js_sys::Reflect; use leptos::prelude::*; use send_wrapper::SendWrapper; +use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure; -use wasm_bindgen::{JsCast, JsValue}; -use wasm_bindgen_futures::JsFuture; use web_sys::CryptoKey; use crate::api::{self, Failure}; use crate::keys::{self, KeyError, Realtime}; +use crate::stdb_bridge::{await_promise, js_error, load_bridge, method, parse, uri}; /// The bridge's fingerprinted path, from `cargo xtask dist`; a build /// without it (`cargo build` alone) has no Messages. @@ -237,21 +235,6 @@ impl Live { } } -/// The database's host: maincloud, except on a development server on this -/// machine, where `localStorage["allthing-stdb-uri"]` may name a local -/// SpacetimeDB (`ws://127.0.0.1:`) to test against. -fn uri() -> String { - let local = web_sys::window().and_then(|w| { - let host = w.location().hostname().ok()?; - if host != "127.0.0.1" && host != "localhost" { - return None; - } - let uri = w.local_storage().ok()??.get_item("allthing-stdb-uri").ok()??; - (uri.starts_with("ws://127.0.0.1:") || uri.starts_with("ws://localhost:")).then_some(uri) - }); - local.unwrap_or_else(|| STDB_URI.to_owned()) -} - fn key_error(error: KeyError) -> String { match error { KeyError::Unsupported => { @@ -260,68 +243,3 @@ fn key_error(error: KeyError) -> String { KeyError::Failed(why) => why, } } - -fn js_error(error: JsValue) -> String { - Reflect::get(&error, &"message".into()) - .ok() - .and_then(|m| m.as_string()) - .or_else(|| error.as_string()) - .unwrap_or_else(|| "Something went wrong".into()) -} - -fn method(object: &JsValue, name: &str) -> Result { - Reflect::get(object, &name.into()) - .ok() - .and_then(|f| f.dyn_into::().ok()) - .ok_or_else(|| format!("The database bridge has no {name}")) -} - -async fn await_promise(value: JsValue) -> Result { - match value.dyn_into::() { - Ok(promise) => JsFuture::from(promise).await.map_err(js_error), - Err(value) => Ok(value), - } -} - -fn parse(value: JsValue) -> Result { - serde_json::from_str(&value.as_string().unwrap_or_default()).map_err(|_| "The database answered oddly".into()) -} - -/// The bridge's `window.allthingStdb`, its script added the first time. -async fn load_bridge(path: &str) -> Result { - let window = web_sys::window().ok_or("No window")?; - let present = |w: &web_sys::Window| Reflect::get(w, &"allthingStdb".into()).ok().filter(|v| v.is_object()); - if let Some(global) = present(&window) { - return Ok(global); - } - let document = window.document().ok_or("No document")?; - let src = format!("{}{path}", crate::util::BASE); - let (script, added) = match document.query_selector("script[data-stdb]").ok().flatten() { - Some(script) => (script, true), - None => { - let script = document.create_element("script").map_err(js_error)?; - let _ = script.set_attribute("type", "module"); - let _ = script.set_attribute("data-stdb", ""); - let _ = script.set_attribute("src", &src); - (script, false) - } - }; - let loaded = Promise::new(&mut |resolve, reject| { - let ok = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); - }); - let failed = Closure::once_into_js(move || { - let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("Couldn't load Messages; check your connection")); - }); - let _ = script.add_event_listener_with_callback("load", ok.unchecked_ref()); - let _ = script.add_event_listener_with_callback("error", failed.unchecked_ref()); - }); - if !added { - let head = document.query_selector("head").ok().flatten().ok_or("No head")?; - head.append_child(&script).map_err(js_error)?; - } - if present(&window).is_none() { - JsFuture::from(loaded).await.map_err(js_error)?; - } - present(&window).ok_or_else(|| "Messages didn't load".into()) -} diff --git a/crates/allthing-web/src/pages/notifications.rs b/crates/allthing-web/src/pages/notifications.rs index 9f5b00ca..c7cc3d84 100644 --- a/crates/allthing-web/src/pages/notifications.rs +++ b/crates/allthing-web/src/pages/notifications.rs @@ -215,7 +215,20 @@ pub mod with_worker { let Some(notifications) = use_notifications() else { return ().into_any() }; // What the reader had seen before this visit: anything newer stays // marked new until they leave. - let before = notifications.seen_by(fid); + let before = RwSignal::new(notifications.seen_by(fid)); + let generation = notifications.generation(); + let waiting_for_sync = StoredValue::new(notifications.sync_baseline(fid).is_none()); + // A cold session may learn the prior account watermark after the + // list paints. Reconcile once, before this visit's read is uploaded; + // later reads in other sessions must not remove this visit's tint. + Effect::new(move |_| { + if waiting_for_sync.get_value() + && let Some(remote) = notifications.sync_baseline(fid) + { + before.update(|seen| *seen = (*seen).max(remote)); + waiting_for_sync.set_value(false); + } + }); // The first page, read whole from the answer the bell read its // times from (a read within a minute is reused), and again as they // change, until more are loaded after it; then the pages stay as @@ -229,13 +242,16 @@ pub mod with_worker { // after its cached answer expires or while the tab is hidden. let times = Memo::new(move |_| notifications.times()); Effect::new(move |_| { - if times.get().is_none() || !notifications.are_for(fid) || !more.with_untracked(Vec::is_empty) { + if times.get().is_none() + || !notifications.same_session(fid, generation) + || !more.with_untracked(Vec::is_empty) + { return; } spawn_local(async move { let read = api::get::(NOTIFICATIONS_PATH).await; // Signed out, or someone else, meanwhile. - if !notifications.are_for(fid) { + if !notifications.same_session(fid, generation) { return; } match read { @@ -288,7 +304,7 @@ pub mod with_worker { failed_more.set(None); spawn_local(async move { let read = api::get::(¬ifications_path(Some(&cursor))).await; - if !notifications.are_for(fid) { + if !notifications.same_session(fid, generation) { return; } match read { @@ -324,7 +340,7 @@ pub mod with_worker { {move || shown.with(Vec::is_empty).then(|| view! {

{chosen(tab).empty()}

})}
    - before} cards group/> +
{move || next.get().map(|_| view! { @@ -364,7 +380,7 @@ pub mod with_worker { /// that person's profile card), the cast it's about, and when. The row /// opens the cast, or for follows the newest follower's profile. #[component] - fn Row(group: Group, new: bool, cards: Memo>) -> impl IntoView { + fn Row(group: Group, before: ReadSignal, cards: Memo>) -> impl IntoView { let (icon, kind) = look(group.kind); let href = match group.opens() { Some(Opens::Cast(hash)) => cast_href(hash), @@ -376,7 +392,8 @@ pub mod with_worker { Some(text) => format!("{}: {text}", group.sentence()), None => group.sentence(), }; - let label = if new { format!("New: {what}") } else { what }; + let new = move || group.ts > before.get(); + let label = move || if new() { format!("New: {what}") } else { what.clone() }; let first = group.first().cloned(); let card = first.as_ref().and_then(|person| cards.with_untracked(|c| c.get(&person.fid).cloned())); let faces = group.actors.clone(); diff --git a/crates/allthing-web/src/stdb_bridge.rs b/crates/allthing-web/src/stdb_bridge.rs new file mode 100644 index 00000000..aab0a3d4 --- /dev/null +++ b/crates/allthing-web/src/stdb_bridge.rs @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! Shared access to the optional SpacetimeDB SDK bridge. Concurrent callers +//! reuse one module script; each owns and cleans up its own load listeners. + +use std::cell::RefCell; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; +use std::task::{Context, Poll, Waker}; + +use allthing_core::stdb::STDB_URI; +use js_sys::{Function, Promise, Reflect}; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; + +const LOAD_TIMEOUT_MS: i32 = 30_000; +const WAITERS: &str = "data-stdb-waiters"; +const LOAD_FAILED: &str = "Couldn't load Messages; check your connection"; +const LOAD_TIMEOUT: &str = "The database bridge took too long to load; please try again"; + +/// Maincloud, except for an explicitly configured local development database. +pub(crate) fn uri() -> String { + let local = web_sys::window().and_then(|w| { + let host = w.location().hostname().ok()?; + if host != "127.0.0.1" && host != "localhost" { + return None; + } + let uri = w.local_storage().ok()??.get_item("allthing-stdb-uri").ok()??; + (uri.starts_with("ws://127.0.0.1:") || uri.starts_with("ws://localhost:")).then_some(uri) + }); + local.unwrap_or_else(|| STDB_URI.to_owned()) +} + +pub(crate) fn js_error(error: JsValue) -> String { + Reflect::get(&error, &"message".into()) + .ok() + .and_then(|m| m.as_string()) + .or_else(|| error.as_string()) + .unwrap_or_else(|| "Something went wrong".into()) +} + +pub(crate) fn method(object: &JsValue, name: &str) -> Result { + Reflect::get(object, &name.into()) + .ok() + .and_then(|f| f.dyn_into::().ok()) + .ok_or_else(|| format!("The database bridge has no {name}")) +} + +/// Keep the host's timer handle intact. Native browsers use integer IDs; +/// alternate hosts can return wider numbers or opaque handles. Narrowing to +/// web-sys's i32 would leave an owned callback live after its Rust owner drops. +pub(crate) fn set_timeout(callback: &JsValue, millis: i32) -> Result { + let window = web_sys::window().ok_or("No window")?; + method(window.as_ref(), "setTimeout")?.call2(window.as_ref(), callback, &millis.into()).map_err(js_error) +} + +pub(crate) fn clear_timeout(handle: &JsValue) { + if let Some(window) = web_sys::window() + && let Ok(clear) = method(window.as_ref(), "clearTimeout") + { + let _ = clear.call1(window.as_ref(), handle); + } +} + +/// Await an SDK promise, also accepting bridge methods that return a value. +/// Operation-specific deadlines belong to the caller; this preserves Messages' +/// existing reducer/connection behavior. +pub(crate) async fn await_promise(value: JsValue) -> Result { + match value.dyn_into::() { + Ok(promise) => JsFuture::from(promise).await.map_err(js_error), + Err(value) => Ok(value), + } +} + +pub(crate) fn parse(value: JsValue) -> Result { + serde_json::from_str(&value.as_string().unwrap_or_default()).map_err(|_| "The database answered oddly".into()) +} + +fn present(window: &web_sys::Window) -> Option { + Reflect::get(window, &"allthingStdb".into()).ok().filter(|v| v.is_object()) +} + +/// Load `window.allthingStdb` once. There is no await between looking up the +/// script and appending it, so Messages and notification sync join the same +/// `script[data-stdb]`. A failed load removes its tag so a later call can retry. +pub(crate) async fn load_bridge(path: &str) -> Result { + let window = web_sys::window().ok_or("No window")?; + if let Some(global) = present(&window) { + return Ok(global); + } + let document = window.document().ok_or("No document")?; + let (script, added) = match document.query_selector("script[data-stdb]").map_err(js_error)? { + Some(script) => (script, true), + None => { + let script = document.create_element("script").map_err(js_error)?; + script.set_attribute("type", "module").map_err(js_error)?; + script.set_attribute("data-stdb", "").map_err(js_error)?; + script.set_attribute("src", &format!("{}{path}", crate::util::BASE)).map_err(js_error)?; + (script, false) + } + }; + let loading = ScriptLoad::new(&script)?; + if !added { + let head = document.query_selector("head").map_err(js_error)?.ok_or("No head")?; + head.append_child(&script).map_err(js_error)?; + } + // The SDK may already have become available while a prior caller waited. + if present(&window).is_none() { + loading.await?; + } + match present(&window) { + Some(global) => Ok(global), + None => { + script.remove(); + Err("Messages didn't load".into()) + } + } +} + +#[derive(Default)] +struct LoadState { + outcome: Option>, + waker: Option, +} + +impl LoadState { + /// A late error/timeout must never replace the result that woke the caller. + fn finish(state: &RefCell, outcome: Result<(), &'static str>) { + let waker = { + let mut state = state.borrow_mut(); + if state.outcome.is_some() { + return; + } + state.outcome = Some(outcome); + state.waker.take() + }; + if let Some(waker) = waker { + waker.wake(); + } + } +} + +/// Unlike `Closure::once_into_js`, these callbacks have an owner even when the +/// async caller is cancelled. Dropping the future detaches both listeners, +/// clears the timer and drops the closures; no forgotten Rust callback remains. +struct ScriptLoad { + script: web_sys::Element, + state: Rc>, + loaded: Closure, + failed: Closure, + timeout: Closure, + timer: Option, +} + +impl ScriptLoad { + fn new(script: &web_sys::Element) -> Result { + let waiters = waiter_count(script).checked_add(1).ok_or("Too many database bridge loads")?; + script.set_attribute(WAITERS, &waiters.to_string()).map_err(js_error)?; + let state = Rc::new(RefCell::new(LoadState::default())); + let loaded_state = state.clone(); + let failed_state = state.clone(); + let timeout_state = state.clone(); + let mut loading = Self { + script: script.clone(), + state, + loaded: Closure::new(move || LoadState::finish(&loaded_state, Ok(()))), + failed: Closure::new(move || LoadState::finish(&failed_state, Err(LOAD_FAILED))), + timeout: Closure::new(move || LoadState::finish(&timeout_state, Err(LOAD_TIMEOUT))), + timer: None, + }; + script.add_event_listener_with_callback("load", loading.loaded.as_ref().unchecked_ref()).map_err(js_error)?; + script.add_event_listener_with_callback("error", loading.failed.as_ref().unchecked_ref()).map_err(js_error)?; + loading.timer = Some(set_timeout(loading.timeout.as_ref(), LOAD_TIMEOUT_MS)?); + Ok(loading) + } +} + +fn waiter_count(script: &web_sys::Element) -> usize { + script.get_attribute(WAITERS).and_then(|s| s.parse().ok()).unwrap_or(0) +} + +impl Future for ScriptLoad { + type Output = Result<(), String>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut state = self.state.borrow_mut(); + match state.outcome { + Some(outcome) => Poll::Ready(outcome.map_err(str::to_owned)), + None => { + state.waker = Some(cx.waker().clone()); + Poll::Pending + } + } + } +} + +impl Drop for ScriptLoad { + fn drop(&mut self) { + let _ = self.script.remove_event_listener_with_callback("load", self.loaded.as_ref().unchecked_ref()); + let _ = self.script.remove_event_listener_with_callback("error", self.failed.as_ref().unchecked_ref()); + if let Some(timer) = self.timer.take() { + clear_timeout(&timer); + } + let remaining = waiter_count(&self.script).saturating_sub(1); + if remaining == 0 { + let _ = self.script.remove_attribute(WAITERS); + } else { + let _ = self.script.set_attribute(WAITERS, &remaining.to_string()); + } + let outcome = self.state.borrow().outcome; + // Cancelling the final pending waiter must not leave a tag whose load + // event nobody can observe. Other concurrent callers keep theirs. + if outcome.is_some_and(|result| result.is_err()) || (outcome.is_none() && remaining == 0) { + self.script.remove(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::Wake; + + use super::*; + + struct Woken(AtomicUsize); + impl Wake for Woken { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + #[test] + fn first_load_result_wins_and_wakes_only_once() { + let woken = Arc::new(Woken(AtomicUsize::new(0))); + let state = RefCell::new(LoadState { outcome: None, waker: Some(Waker::from(woken.clone())) }); + LoadState::finish(&state, Err(LOAD_FAILED)); + LoadState::finish(&state, Ok(())); + LoadState::finish(&state, Err(LOAD_TIMEOUT)); + assert_eq!(state.borrow().outcome, Some(Err(LOAD_FAILED))); + assert!(state.borrow().waker.is_none()); + assert_eq!(woken.0.load(Ordering::Relaxed), 1); + } + + #[test] + fn load_before_the_first_poll_is_not_lost() { + let state = RefCell::new(LoadState::default()); + LoadState::finish(&state, Ok(())); + LoadState::finish(&state, Err(LOAD_TIMEOUT)); + assert_eq!(state.borrow().outcome, Some(Ok(()))); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d774ec9b..e57cbf91 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Architecture -Allthing is a Farcaster client for reading: it shows Farcaster, and the Worker posts nothing of its own. It is one Cloudflare Worker that serves two things: a static WebAssembly app, and a small JSON API that the app calls once per page. Readers can [sign in with Farcaster](#sign-in), by scanning a QR code with the Farcaster app, by one approval that also lets them [like, recast, cast, reply, quote, follow and delete](#liking-and-recasting) with a key their browser keeps; the Worker holds [scheduled casts](#scheduled-casts) the browser signed until their time. [Messages](#messages-on-spacetimedb) are SpacetimeDB chat and end-to-end encrypted DMs. The Worker also keeps the images and videos readers [attach](#attachments) to their casts, in R2, and serves them at `/m/`. Application behavior and most build/check tooling are Rust. The early-card renderer stays in JavaScript after a measured cold-load comparison; the loader and inline hints start data and WebAssembly downloads; Messages uses a narrow TypeScript adapter to its official SDK. Generated bindings, the official Playwright TypeScript test runner and the Node static-site test server stay at those platform boundaries. +Allthing is a Farcaster client for reading: it shows Farcaster, and the Worker posts nothing of its own. It is one Cloudflare Worker that serves two things: a static WebAssembly app, and a small JSON API that the app calls once per page. Readers can [sign in with Farcaster](#sign-in), by scanning a QR code with the Farcaster app, by one approval that also lets them [like, recast, cast, reply, quote, follow and delete](#liking-and-recasting) with a key their browser keeps; the Worker holds [scheduled casts](#scheduled-casts) the browser signed until their time. [Messages](#messages-on-spacetimedb) are SpacetimeDB chat and end-to-end encrypted DMs. The Worker also keeps the images and videos readers [attach](#attachments) to their casts, in R2, and serves them at `/m/`. Application behavior and most build/check tooling are Rust. The early-card renderer stays in JavaScript after a measured cold-load comparison; the loader and inline hints start data and WebAssembly downloads; Messages and notification read-state sync share a narrow TypeScript adapter to the official SpacetimeDB SDK. Generated bindings, the official Playwright TypeScript test runner and the Node static-site test server stay at those platform boundaries. ```text Browser Cloudflare (one Worker, Free plan) Upstreams @@ -37,19 +37,19 @@ Allthing is a Farcaster client for reading: it shows Farcaster, and the Worker p | Crate | Target | Role | |---|---|---| -| `allthing-core` | native + wasm32 | Everything that can be pure: the data model, normalizing Hypersnap's JSON, ranking, cast text tokenizing, embed classification and image sizes, HLS playlist parsing, provenance decoding, wallet shares, the content spam filter, profile cards, the messages a reader signs and their app key's request (`message`, `app_key`), a reader's notifications grouped (`notifications`), upstream guards (token bucket, breaker, budget), the API payload types and cache policies, and the rules of readers' attachments (`media`: their types, sizes, quotas, names and ranges). The end-to-end encryption of messages after the X25519 agreement (`e2e`: HKDF-SHA256, XChaCha20-Poly1305). Only `serde`, `serde_json`, `blake3` (a message's hash) and RustCrypto's `hkdf`, `sha2` and `chacha20poly1305`; no I/O. Most unit tests live here and run against real Hypersnap responses in `tests/fixtures`. | +| `allthing-core` | native + wasm32 | Everything that can be pure: the data model, normalizing Hypersnap's JSON, ranking, cast text tokenizing, embed classification and image sizes, HLS playlist parsing, provenance decoding, wallet shares, the content spam filter, profile cards, the messages a reader signs and their app key's request (`message`, `app_key`), a reader's notifications grouped (`notifications`) and monotonic read progress (`notification_state::ReadState`), upstream guards (token bucket, breaker, budget), the API payload types and cache policies, and the rules of readers' attachments (`media`: their types, sizes, quotas, names and ranges). The end-to-end encryption of messages after the X25519 agreement (`e2e`: HKDF-SHA256, XChaCha20-Poly1305). Only `serde`, `serde_json`, `blake3` (a message's hash) and RustCrypto's `hkdf`, `sha2` and `chacha20poly1305`; no I/O. Most unit tests live here and run against real Hypersnap responses in `tests/fixtures`. | | `allthing-data` | native + wasm32 | The page builders that assemble each payload, and the Hypersnap and RPC clients behind guards. The runtime is abstracted as a `Host` (clock, HTTP with a timeout, background work), so the same code runs in the Worker and in the browser. | | `allthing-worker` | wasm32 (Worker) | The `/api/v1` handler: routing, the per-IP rate limit, the edge cache with coalescing, and a `Host` over Workers `fetch`, `AbortController`, `waitUntil` and D1. Its cron handler runs the indexer. `media.rs` keeps readers' [attachments](#attachments) in R2 and serves them. `auth.rs` is [sign-in](#sign-in) and `app_key.rs` [endorses readers' keys](#liking-and-recasting); between them they hold the Worker's cryptography (secp256k1 signer recovery and signing, Keccak-256, HMAC-SHA256) and the QR encoder, none of which ships to browsers. A reader's own key is their browser's (WebCrypto's Ed25519). | | `allthing-web` | wasm32 (browser) | The Leptos app (client-side rendering): routes, pages, components, the media viewer, the HLS player, prefetching and scroll restoration, and liking and recasting with the reader's key (`keys.rs`, `components/writer.rs`). With the `static-host` feature it builds payloads in-process through a browser `Host`. | | `xtask` | native | Build orchestration, Brotli-11 compression, esbuild CLI/SDK-version checks, metadata and credit audits, attestation key generation, optimizer discovery, fixture phases, npm audit, isolated SpacetimeDB validation and deployment waiting. `dist` builds `dist/`; `pages` builds `dist-pages/`. | -`scripts/build.sh` only bootstraps rustup and the pinned toolchain on images without Rust, then runs `cargo xtask build`. The native build selects the locked tools, builds the Worker, strips its relocations and builds the browser assets. `cargo xtask dist` runs `cargo build`, wasm-split for the interactive app, `wasm-bindgen --target web` and `wasm-opt -Oz`. It writes fingerprinted Wasm, glue, loaders, CSS and HTML, and compresses the optional `.wasm.br` copies in Rust at quality 11, window 24. The Messages adapter is bundled through the installed esbuild CLI after checking its SDK version. WebAssembly release builds use `opt-level = "z"`, LTO, one codegen unit and `panic = "abort"`. +`scripts/build.sh` only bootstraps rustup and the pinned toolchain on images without Rust, then runs `cargo xtask build`. The native build selects the locked tools, builds the Worker, strips its relocations and builds the browser assets. `cargo xtask dist` runs `cargo build`, wasm-split for the interactive app, `wasm-bindgen --target web` and `wasm-opt -Oz`. It writes fingerprinted Wasm, glue, loaders, CSS and HTML, and compresses the optional `.wasm.br` copies in Rust at quality 11, window 24. The shared SpacetimeDB adapter is bundled through the installed esbuild CLI after checking its SDK version. WebAssembly release builds use `opt-level = "z"`, LTO, one codegen unit and `panic = "abort"`. ## Language boundaries Rust is the default for new application logic and native tooling. Pure shared behavior belongs in `allthing-core`; the interactive app, data layer, Worker and SpacetimeDB module keep their separate Rust targets. Build, filesystem, validation and deployment policy belongs in `xtask`. -The JavaScript early-card renderer (`web/paint.js`) is an explicit measured exception: the owner kept it after it beat the Rust prototype in a [paired cold-load comparison](PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer). The small `boot.js` loader and inline data/preload hints also run before WebAssembly can start. Messages retains the official TypeScript SDK adapter and generated bindings; browser tests retain Playwright's official TypeScript runner, browser-side probes, independent protocol oracles and the existing Node static test server (`e2e/pages-server.mjs`). The test server stays after an unresolved pre-mount stall during migration CI, with causality unproven; [PERFORMANCE.md](PERFORMANCE.md#retained-node-static-test-server) records the conservative rollback. Wrangler and esbuild remain platform tools. CSS, SQL, HTML, fixtures and configuration stay in their appropriate formats. Keep exceptions narrow and documented; preserve licensing and measured behavior instead of reclassifying handwritten files to change language statistics. +The JavaScript early-card renderer (`web/paint.js`) is an explicit measured exception: the owner kept it after it beat the Rust prototype in a [paired cold-load comparison](PERFORMANCE.md#measured-exception-the-javascript-first-card-renderer). The small `boot.js` loader and inline data/preload hints also run before WebAssembly can start. Messages and notification read-state sync retain the official TypeScript SDK adapter and generated bindings; browser tests retain Playwright's official TypeScript runner, browser-side probes, independent protocol oracles and the existing Node static test server (`e2e/pages-server.mjs`). The test server stays after an unresolved pre-mount stall during migration CI, with causality unproven; [PERFORMANCE.md](PERFORMANCE.md#retained-node-static-test-server) records the conservative rollback. Wrangler and esbuild remain platform tools. CSS, SQL, HTML, fixtures and configuration stay in their appropriate formats. Keep exceptions narrow and documented; preserve licensing and measured behavior instead of reclassifying handwritten files to change language statistics. ## Browser-local seen posts @@ -452,9 +452,10 @@ A [signed-in](#sign-in) reader's notifications: who liked, recasted, replied to, - **What's left out**: the reader's own doings, and replies, mentions and quotes that are spam or mini apps (`Cast::hidden`, their links checked as a listing's are), as listings leave them out; never anything for who did it. - **Grouped as the Farcaster app groups them** (`notifications::group`): likes of one cast together ("alice and 5 others liked your cast"), recasts of one cast together, follows on one UTC day together ("alice and 3 others followed you"), and each reply, mention and quote on its own. A person counts once in a group, which keeps its 5 newest people and how many there are. Groups are in the order of their newest event. A page is a page of Hypersnap's: likes of one cast on two pages are two groups. - **The payload** (`NotificationsPayload`) holds the groups (each with its kind, people, count, newest time, and the cast it's about: its hash and its start, `notifications::excerpt`), the cursor of the next page, the newest time, and in `notes` the profile cards of the people shown, from the user objects that come with them, and the verdicts on their casts' links, carried to the next build. It is cached at the edge per account and page, fresh for a minute and served while one rebuild runs for ten, and answered `private, no-store`, so no browser or shared cache keeps one reader's for another. -- **Read state is the reader's browser's.** The server keeps none: there is no D1 table for it in this first version. The app keeps the newest time its reader has seen in localStorage (`allthing-notifications-seen`, `{"": }`), and counts the groups newer than it as unread (`notifications::unread`); opening the page marks the payload's `newest` as seen, and the browser's other tabs follow (the `storage` event). Another browser or device starts unread. +- **Read state follows the account.** The app keeps the newest notification time seen in the existing localStorage cache (`allthing-notifications-seen`, `{"": }`), so local reads and other tabs still work offline. `allthing_core::notification_state::ReadState` merges local progress, acknowledged writes and remote progress by maximum; failed writes remain pending, stale values never move the marker back, and invalid/future clocks cannot poison it. SpacetimeDB keeps only a private identity-to-FID binding (`notification_reader`) and one `notification_state(fid, seen_at)` watermark per account, with no notification content. Its `my_notification_state` view selects only the caller's attested FID. Tabs stay in the URL; only read progress syncs. +- **Background synchronization** (`notification_sync.rs`) is initially scheduled two seconds after account setup, waits for the page's API work to settle and browser idle time, and connects only while visible and online. A cached `/api/v1/status` capability check must advertise `spacetime.attest` before the shared SDK or socket loads. It uses a notification-only identity/token in IndexedDB `allthing-notification-sync`, store `identities`, then a Worker attestation, `verify_notification_fid` and one subscription to `my_notification_state`. It never registers a chat user, publishes a DM key, subscribes to chat or changes public presence. Writes coalesce for 200 ms with one in flight; remote updates are not echoed. Connection/subscription/reducer waits are bounded to 30 seconds; failures retry with backoff, and online/focus/visibility events wake sync. Sign-out/account changes stop the old connection and fence late completions, including A → B → A. - **The bell** (`components/notifications.rs`), for a signed-in reader, sits beside the account's picture in the header: a link to `/notifications` with a badge of the groups not seen ("9+" past nine), named "Notifications, N unread" for screen readers, and marked as the current page there. The app reads the first page when it starts, every 3 minutes while the page is in view, and when it comes back into view or its window regains focus; a read within a minute is reused (`api`). When someone signs out or someone else signs in, here or in another tab, the app forgets the last reader's answers, and keeps none still on its way, and the page starts its list anew, so nothing of one reader's shows for the next. The bell decodes only each group's time (`notifications::Times`), so the module carries no reader of the whole payload. The static copy has no bell. -- **The page**, `/notifications` (`pages/notifications.rs`), is code of its own (`split_notifications.wasm`), fetched as the bell is pointed at, touched or focused, or with the app when a visit starts on it (`index.html`'s early parts). It has the tabs All, Mentions (mentions, and quotes that mention the reader) and Replies, kept in the URL (`?tab=`) and moved through with the arrow keys, Home and End as a tab list. Each row has its kind's icon, up to 5 faces, its sentence ("**Alice** and 5 others liked your cast"), the cast's start with its Pepe as cards show them (`ExcerptText`), and when. A row opens its cast, or for follows the newest follower's profile; a face or a name links to that profile and opens its profile card. Groups newer than what the reader had seen before the visit are tinted, with an accent bar, for the whole visit, while opening the page marks everything up to its newest seen, so the bell clears. **Load more** reads the next page by its cursor, after the first; then the first page stays as it is, so nothing slips between two. Signed out, the page asks the reader to sign in; a read that fails says why, with **Try again**; placeholder rows hold the list's place while the data comes. On a touch screen, each face and name takes a 44 px tap of its own. +- **The page**, `/notifications` (`pages/notifications.rs`), is code of its own (`split_notifications.wasm`), fetched as the bell is pointed at, touched or focused, or with the app when a visit starts on it (`index.html`'s early parts). It has the tabs All, Mentions (mentions, and quotes that mention the reader) and Replies, kept in the URL (`?tab=`) and moved through with the arrow keys, Home and End as a tab list. Each row has its kind's icon, up to 5 faces, its sentence ("**Alice** and 5 others liked your cast"), the cast's start with its Pepe as cards show them (`ExcerptText`), and when. A row opens its cast, or for follows the newest follower's profile; a face or a name links to that profile and opens its profile card. Groups newer than what the reader had seen before the visit are tinted, with an accent bar, while opening the page marks everything up to its newest seen, so the bell clears. If the initial remote watermark arrives after a cold page paints, it reconciles that visit's baseline once before pending local progress is uploaded; subsequent remote reads update the bell/cache without changing that visit's row tint. **Load more** reads the next page by its cursor, after the first; then the first page stays as it is, so nothing slips between two. Signed out, the page asks the reader to sign in; a read that fails says why, with **Try again**; placeholder rows hold the list's place while the data comes. On a touch screen, each face and name takes a 44 px tap of its own. - **What it can't see.** Recasts come only as Hypersnap's likes do, and the hub's newest 10 a shard, for 6 casts a page; quotes only of the reader's 4 newest casts, with the first page. Hypersnap names no other kinds. ### Counts as current as notifications @@ -464,7 +465,7 @@ Notifications and counts come from the same node (Hypersnap), and a like or repl ## Messages on SpacetimeDB The public `#general` room and end-to-end encrypted conversations (Messages) live in a SpacetimeDB database, `allthing` on maincloud, off Farcaster: nothing is written to Snapchain. The module is in `spacetime/spacetimedb` (its handoff, schema and reducers in `spacetime/HANDOFF.md`), checked by CI's `SpacetimeDB module` job. - **Who is who.** A browser connects with its own SpacetimeDB identity. After Sign In With Farcaster, it asks the Worker for an attestation (`POST /api/v1/auth/attest`): the Ed25519 signature, by the `ATTEST_KEY` secret, of its identity, the signed-in account and an expiry 10 minutes on (`allthing_core::stdb`, `crates/allthing-worker/src/attest.rs`). The module's `verify_fid` checks it against the public half pinned in `attest_key.rs` (ed25519-dalek, strict), against the caller's own identity, so an attestation is no use to anyone else, and marks the identity's FID verified. Once a key is pinned, only verified identities can publish a key, chat, create rooms or send messages. The key is the Worker's own; it is never the custody key, and the Worker never holds a reader's key. -- **The client.** `/messages`, from the account menu, is one inbox (`pages/messages`): a list of conversations, Allthing's public `#general` room first, then the reader's encrypted conversations, newest first; the one open beside it (`?room=general`, `?to=`), or New message (`?new=1`), which finds a person by name with the site's search. On a phone the list and the open conversation take turns, with a back button. Enter sends, Shift+Enter starts a new line. It asks for no API key and calls no Farcaster Direct Cast API: Farcaster's own Direct Casts stay in the Farcaster app. The page is a split part of its own (`split_realtime.wasm`, `pages/messages/realtime`), and it loads the SpacetimeDB TypeScript SDK as one bundled module script, `assets/stdb-.js` (`web/stdb/bridge.ts`, `xtask/src/browser_assets.rs`), for a signed-in reader only. The Rust SDK builds for browsers but is BSL-1.1, which the license audit refuses. No other page fetches either. Names and pictures come from Hypersnap's `user/bulk`. The browser keeps its SpacetimeDB token and an X25519 key pair (WebCrypto, unexportable) in IndexedDB `allthing-stdb`, one record per account. After connecting it calls `register`, then `verify_fid` with the Worker's attestation, then `set_public_key`. Signed out, the page asks the reader to sign in; the static copy points to allthing.social. Tested by `e2e/realtime-messages.fixture.spec.ts` at 1280 and 390, with a stand-in database (axe included). +- **The client.** `/messages`, from the account menu, is one inbox (`pages/messages`): a list of conversations, Allthing's public `#general` room first, then the reader's encrypted conversations, newest first; the one open beside it (`?room=general`, `?to=`), or New message (`?new=1`), which finds a person by name with the site's search. On a phone the list and the open conversation take turns, with a back button. Enter sends, Shift+Enter starts a new line. It asks for no API key and calls no Farcaster Direct Cast API: Farcaster's own Direct Casts stay in the Farcaster app. The page is a split part of its own (`split_realtime.wasm`, `pages/messages/realtime`), and it shares the SpacetimeDB TypeScript SDK, loaded once as a bundled module script, `assets/stdb-.js` (`web/stdb/bridge.ts`, `xtask/src/browser_assets.rs`), for a signed-in reader only. The Rust SDK builds for browsers but is BSL-1.1, which the license audit refuses. The Messages split part stays page-specific; eligible signed-in notification sync may load the shared SDK earlier through `stdb_bridge::load_bridge`. Names and pictures come from Hypersnap's `user/bulk`. The browser keeps its SpacetimeDB token and an X25519 key pair (WebCrypto, unexportable) in IndexedDB `allthing-stdb`, one record per account. After connecting it calls `register`, then `verify_fid` with the Worker's attestation, then `set_public_key`. Signed out, the page asks the reader to sign in; the static copy points to allthing.social. Tested by `e2e/realtime-messages.fixture.spec.ts` at 1280 and 390, with a stand-in database (axe included). - **Content Security Policy.** SpacetimeDB 2.13.0 builds product and sum codecs with `Function`, which the site’s existing policy rejects. `web/stdb/csp-codec.ts`, imported before the generated bindings, replaces only those public codec factories with cached closures; SDK primitive and array codecs and BSATN wire encoding stay in use. `xtask/src/browser_assets.rs` requires a deliberate adapter review when the SDK version changes. `tests/stdb-codecs.test.mjs` compares wire bytes and decoded values with the unmodified SDK, including recursive types and inherited field names. `e2e/stdb-csp.fixture.spec.ts` runs the actual bundled SDK under the production policy against a BSATN WebSocket peer and mock Worker attestation: connection, rows, online count, verification and sending must work without a CSP violation. The mock attestation does not replace the release check with a real signed-in account and the live module. No `unsafe-eval` permission is added. - **Encryption** is in `allthing_core::e2e`: X25519 (WebCrypto), then HKDF-SHA256, then XChaCha20-Poly1305, with associated data that binds both parties, their key versions and their keys. The explainer (`components/encryption.rs`, text in `e2e::messages_explainer`) is reusable by later encrypted features. diff --git a/docs/BUDGET.md b/docs/BUDGET.md index dc1462ad..bfc5b77e 100644 --- a/docs/BUDGET.md +++ b/docs/BUDGET.md @@ -43,6 +43,7 @@ The page-payload API counts below are unchanged. Code assets are cached separate | Back/forward within 5 min | **0** | none: in-memory client cache | | Being signed in | **0**: the header reads who is signed in from a cookie (its bell's reads are the next row) | none | | The bell, for a signed-in reader | 1 (`/api/v1/notifications`) when the app starts, then 1 every 3 minutes while the page is in view, and 1 when it comes back into view or its window regains focus. A read under a minute old is reused and one on its way is shared, so at most 1 a minute: about 20 an hour for a tab left in view, none while it is hidden. The static copy has no bell | none | +| Notification read-state sync, signed in | On a connection attempt, at most 1 `/api/v1/status` when its five-minute client cache cannot be reused, then 1 `POST /api/v1/auth/attest` per new connection. Status has no upstream call; attestation has no subrequest. Reconnection may repeat these costs; see below | shared `assets/stdb-.js`, once if not already cached | | Notifications `/notifications` | **0** when the bell's read is under a minute old (the page reads the same answer); otherwise 1 (`/api/v1/notifications`). 1 per **Load more** (`?cursor=`) | its code, the first time (`split_notifications.wasm` and the chunks it shares: 13 files, 29.8 KB at brotli 11) | | Being on a page: the header's [presence](ARCHITECTURE.md#presence) chip | **0** per page view: a browser beats (`POST /api/v1/presence`) every 4 to 15 minutes while one of its tabs is visible and in use, never as part of a page view, and one tab beats for all; navigating, reloading and opening tabs in between cost nothing ([§14](#14-presence)) | none | | Signing in | Where the Worker endorses keys, by approving Allthing's key: 5 (`/api/v1/auth/start`, `/signer`, `/qr` on a computer only, `/start` again for a fresh nonce, `/key`); waiting for the approval polls the Farcaster app's API and then Hypersnap from the browser, never the Worker, and a `key` the node can't confirm yet is sent up to 3 more times. Elsewhere, by Sign In With Farcaster: 3 (`/api/v1/auth/start`, `/qr`, `/verify`); waiting for the Farcaster app polls Farcaster's relay from the browser, never the Worker, and a hidden page doesn't poll. A code renewed when it runs out costs 2 more, at most twice; an approval the Worker can't check just now is sent up to 3 more times, then once per "Try again". Signing out: 1 | none | @@ -60,7 +61,7 @@ The page-payload API counts below are unchanged. Code assets are cached separate | Attaching an image or a video to a cast | A small file: 1 `POST /api/v1/media`, backed by three bounded R2 multipart operations so cancellation can fence even a very slow body. A large video: create + one POST per 8 MiB part + complete (34 requests at 250 MiB); Retry resends only the incomplete part, and replaying a recorded part performs no R2 write. The composer also asks `GET /api/v1/media` once per 5 minutes. No upstream call ([§12](#12-attachments-r2)) | none | | Showing a reader's attachment, here or in any other app | 1 per file a browser doesn't have (`/m/…`, an R2 read; each kept a year, as immutable); a video as many as the ranges its player asks for, usually 1 to 3; in an HLS player (the Farcaster app), 2 for its playlists, then 1 for the init and 1 per segment (4 s or more) it plays | none | | Voting in Allthing's poll | 1 (`POST /api/v1/poll`: one D1 batch, no cache call, no upstream call), once a browser: a later visit shows the results it kept, and the counts come with the front page's payload ([§14](#13-allthings-poll)) | none | -| Messages `/messages` ([Messages on SpacetimeDB](ARCHITECTURE.md#messages-on-spacetimedb)) | **1** for a signed-in reader as it connects (`POST /api/v1/auth/attest`: an Ed25519 signature, no subrequest). Everything else goes from the browser to SpacetimeDB over one WebSocket, and to Hypersnap's `user/bulk` for names and pictures, never through the Worker | none | +| Messages `/messages` ([Messages on SpacetimeDB](ARCHITECTURE.md#messages-on-spacetimedb)) | **1** for a signed-in reader as it connects (`POST /api/v1/auth/attest`: an Ed25519 signature, no subrequest). Everything else goes from the browser to SpacetimeDB over one WebSocket, and to Hypersnap's `user/bulk` for names and pictures, never through the Worker | shared SDK module, once if not already cached | | Typing an @mention in the composer | 1 per distinct name, after typing pauses for 200 ms (`/api/v1/search`, the search box's payload, kept in the client cache, so the same name again costs nothing); whether the reader follows each account listed (up to 6) is read from Hypersnap by the browser (`linkById`), once a visit per account | none | | Finding someone to write to | 1 per distinct query, after typing pauses for 250 ms, from 2 characters (`/api/v1/search`, the search box's payload) | none | | Revisit within `max-age` | **0** | none: browser HTTP cache (30 s to 1 h per endpoint) | @@ -89,7 +90,9 @@ A reader who opens posts they hovered spends nothing extra; idle viewport prefet **Early rendering:** `web/paint.js` reuses the already-started Home response and performs no additional API, D1, cron, upstream or Worker call. Its fingerprinted JavaScript is a free immutable static asset; no independent early-renderer Wasm is shipped. The static copy omits early rendering. The API/prefetch capacity estimates above exclude the main app's cold `/wasm/*` transport request; count that against the same 100,000-request allowance when estimating cold traffic. Native tooling changes no application request budget. -**Messages CSP compatibility:** the closure-based SDK codec adapter adds 449 Brotli bytes (2,111 raw bytes) to the Messages-only JavaScript bundle in the measured build. It adds no requests, subscriptions, upstream subrequests or server cache keys; connection and attestation budgets above are unchanged. +**Notification read-state synchronization:** the first attempt is scheduled after two seconds, page API work has settled and browser idle time, while visible and online. The status capability gate runs before loading the shared SDK/socket; a status read costs the existing two cache reads, with no Hypersnap call. One notification-only socket connects directly to SpacetimeDB, verifies its FID and subscribes only to `my_notification_state` (zero or one watermark row). Local advances coalesce for 200 ms, with one write in flight; `mark_notifications_seen` only raises the account's maximum and repeated/remote values add no echo write. Failed writes remain pending and retries may replay them. Retry delays grow from 5 s through 15 s, 1 min and 3 min to a 5-minute cap; connection/subscription/reducer waits are bounded to 30 s. Sync adds no D1, cron or notification-content read. It uses separate credentials and adds no chat subscription, message key or public presence. The capacity estimates above exclude these per-connection status/attestation requests; count them against the same Worker allowance. + +**SDK CSP compatibility:** the earlier closure-based codec change added 449 Brotli bytes (2,111 raw bytes) in its measured build. That historical size predates notification sync; it is not a measurement of the new feature's bundle size. The codec change itself adds no request, subscription, upstream subrequest or server cache key; the shared SDK's notification startup costs are counted above. **Seen posts:** reading history adds no API endpoint, request, upstream subrequest or per-reader server cache key. Hashes and timestamps stay in localStorage (at most 2,000 per reader, 30 days, four readers), with writes batched after a 500 ms delay. One shared viewport observer schedules one one-second timer per eligible card; scroll/resize checks only intersecting cards once per animation frame, with no polling loop or fetch marks reading. Existing infinite scroll may reach its next page earlier when prior-seen rows are omitted, under the same page limit and request budgets. Early paint skips browsers with local history instead of drawing a different first screen from the app. diff --git a/e2e/fixtures/notification-sync-wire.ts b/e2e/fixtures/notification-sync-wire.ts new file mode 100644 index 00000000..ec5ae5c0 --- /dev/null +++ b/e2e/fixtures/notification-sync-wire.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Node-only protocol peer; the browser uses the unmocked, built SDK bridge. +import { BinaryReader, BinaryWriter, ConnectionId, Identity, Timestamp, t } from "spacetimedb"; +import SourceReader from "../../node_modules/spacetimedb/src/lib/binary_reader"; +import SourceWriter from "../../node_modules/spacetimedb/src/lib/binary_writer"; +import { ClientMessage, ServerMessage } from "../../node_modules/spacetimedb/src/sdk/client_api/types"; +import { NotificationState } from "../../web/stdb/module_bindings/types"; +import VerifyNotificationFid from "../../web/stdb/module_bindings/verify_notification_fid_reducer"; +import MarkNotificationsSeen from "../../web/stdb/module_bindings/mark_notifications_seen_reducer"; + +function frame(tag: string, value: unknown) { + const writer = new SourceWriter(1024); + ServerMessage.serialize(writer, { tag, value } as never); + return Buffer.concat([Buffer.from([0]), Buffer.from(writer.getBuffer())]); +} +function rows(state?: { fid: number; seenAt: number }) { + if (!state) return { sizeHint: { tag: "RowOffsets", value: [] }, rowsData: new Uint8Array() }; + const writer = new BinaryWriter(64); + NotificationState.serialize(writer, { fid: BigInt(state.fid), seenAt: BigInt(state.seenAt) }); + const rowsData = writer.getBuffer(); + return { sizeHint: { tag: "FixedSize", value: rowsData.length }, rowsData }; +} +export function initial(identity: string, token: string, connection: number) { + return frame("InitialConnection", { identity: Identity.fromString(identity), connectionId: new ConnectionId(BigInt(connection)), token }); +} +export function snapshot(requestId: number, querySetId: unknown, state?: { fid: number; seenAt: number }) { + return frame("SubscribeApplied", { requestId, querySetId, rows: { tables: [{ table: "my_notification_state", rows: rows(state) }] } }); +} +export function update(querySetId: unknown, before: { fid: number; seenAt: number } | undefined, after: { fid: number; seenAt: number }) { + return frame("TransactionUpdate", { querySets: [{ querySetId, tables: [{ tableName: "my_notification_state", rows: [{ tag: "PersistentTable", value: { deletes: rows(before), inserts: rows(after) } }] }] }] }); +} +export function client(bytes: Buffer | string) { + return ClientMessage.deserialize(new SourceReader(typeof bytes === "string" ? Buffer.from(bytes) : bytes)); +} +export function reducer(name: string, args: Uint8Array) { + if (name === "verify_notification_fid") return t.row(VerifyNotificationFid).deserialize(new BinaryReader(args)); + if (name === "mark_notifications_seen") return t.row(MarkNotificationsSeen).deserialize(new BinaryReader(args)); + throw new Error(`Unexpected notification-sync reducer: ${name}`); +} +export function ok(requestId: number) { + return frame("ReducerResult", { requestId, timestamp: new Timestamp(BigInt(Date.now()) * 1000n), result: { tag: "OkEmpty", value: {} } }); +} +export function error(requestId: number, reason: string) { + return frame("ReducerResult", { requestId, timestamp: new Timestamp(BigInt(Date.now()) * 1000n), result: { tag: "InternalError", value: reason } }); +} diff --git a/e2e/notification-sync.fixture.spec.ts b/e2e/notification-sync.fixture.spec.ts new file mode 100644 index 00000000..a77b1d45 --- /dev/null +++ b/e2e/notification-sync.fixture.spec.ts @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { expect, test, type Browser, type BrowserContext, type Page, type WebSocketRoute } from "@playwright/test"; +import { buildSync } from "esbuild"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; +import { appCame } from "./helpers/app"; +import { signIn } from "./helpers/farcaster"; + +/* + * Notification read watermarks across separate browser storage/session scopes. + * The real built SDK/bridge runs in Chromium; this Node-only peer exchanges + * actual SpacetimeDB BSATN frames and exposes only the authenticated FID view. + * Notification content still comes exclusively from the Worker fixture. + */ +test.skip(Boolean(process.env.E2E_PAGES), "the static copy has no signed-in notifications"); +const temp = mkdtempSync(join(tmpdir(), "allthing-notification-wire-")); +buildSync({ entryPoints: [resolve("e2e/fixtures/notification-sync-wire.ts")], outfile: join(temp, "wire.cjs"), bundle: true, platform: "node", format: "cjs", logLevel: "silent" }); +const wire = createRequire(resolve("package.json"))(join(temp, "wire.cjs")); +test.afterAll(() => rmSync(temp, { recursive: true, force: true })); + +const SEEN = "allthing-notifications-seen"; +const at = Math.floor(Date.now() / 1000); +const alice = { fid: 1234, username: "reader", display_name: "Reader" }; +const bob = { fid: 5678, username: "other", display_name: "Other" }; +const actor = { fid: 88, username: "fixture", display_name: "Fixture" }; +const hash = (n: number) => `0x${n.toString(16).padStart(40, "0")}`; +const meta = () => ({ backend: "hypersnap", fetched_at: Date.now() }); +const payload = (fid: number, newest = at - 300) => ({ + groups: [ + { kind: "reply", actors: [actor], count: 1, ts: newest, cast: { hash: hash(1), excerpt: `Newest notification for ${fid}` } }, + { kind: "like", actors: [actor], count: 1, ts: at - 360, cast: { hash: hash(2), excerpt: `Earlier notification for ${fid}` } }, + ], newest, notes: {}, meta: meta(), +}); +type Watermark = { fid: number; seenAt: number }; +type Connection = { label: string; socket: WebSocketRoute; identity: string; token: string; active: boolean; fid?: number; querySetId?: unknown; row?: Watermark }; + +class Peer { + connections: Connection[] = []; + tokens = new Map(); + authorized = new Map(); + states = new Map(); + offline = new Set(); + calls: { label: string; identity: string; name: string; fid?: number; seenAt?: number }[] = []; + queries: string[] = []; + history: { fid: number; seenAt: number }[] = []; + tokenExchanges: string[] = []; + verified(label: string) { return this.connections.filter(c => c.label === label && c.active && c.fid !== undefined); } + seen(fid: number) { return this.states.get(fid) ?? 0; } + sendState(connection: Connection) { + if (!connection.active || connection.querySetId === undefined || connection.fid === undefined) return; + const seenAt = this.states.get(connection.fid); + if (seenAt === undefined) return; + const row = { fid: connection.fid, seenAt }; + connection.socket.send(wire.update(connection.querySetId, connection.row, row)); + connection.row = row; + } + async attach(page: Page, label: string) { + // The real SDK exchanges a persisted token before opening its next socket. + // Keep that HTTP step inside this peer too: fixture tokens never go live. + await page.route("https://maincloud.spacetimedb.com/**", async route => { + const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization", "access-control-allow-methods": "POST, OPTIONS" }; + if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers }); + const token = (await route.request().headerValue("authorization"))?.replace(/^Bearer /, ""); + if (new URL(route.request().url()).pathname !== "/v1/identity/websocket-token" || !token || !this.tokens.has(token)) { + return route.fulfill({ status: 401, json: { error: "Unknown fixture token" }, headers }); + } + this.tokenExchanges.push(label); + if (this.offline.has(label)) return route.fulfill({ status: 503, json: { error: "Fixture service offline" }, headers }); + return route.fulfill({ json: { token }, headers }); + }); + await page.routeWebSocket("wss://maincloud.spacetimedb.com/**", socket => { + if (this.offline.has(label)) { void socket.close({ code: 1013, reason: "Fixture offline" }); return; } + const requested = new URL(socket.url()).searchParams.get("token"); + const token = requested && this.tokens.has(requested) ? requested : `notification-token-${this.tokens.size + 1}`; + let identity = this.tokens.get(token); + if (!identity) { identity = `c3${(this.tokens.size + 1).toString(16).padStart(62, "0")}`; this.tokens.set(token, identity); } + const connection: Connection = { label, socket, identity, token, active: true }; + this.connections.push(connection); + socket.onClose(() => { connection.active = false; }); + socket.onMessage(bytes => { + const message = wire.client(bytes); + if (message.tag === "Subscribe") { + connection.querySetId = message.value.querySetId; + this.queries.push(...message.value.queryStrings); + const seenAt = connection.fid === undefined ? undefined : this.states.get(connection.fid); + connection.row = seenAt === undefined ? undefined : { fid: connection.fid!, seenAt }; + socket.send(wire.snapshot(message.value.requestId, message.value.querySetId, connection.row)); + } else if (message.tag === "CallReducer") { + const { reducer: name, args, requestId } = message.value; + try { + const call = { label, identity: connection.identity, name, fid: undefined as number | undefined, seenAt: undefined as number | undefined }; + this.calls.push(call); + const decoded = wire.reducer(name, args); + if (name === "verify_notification_fid") { + const fid = Number(decoded.fid); + call.fid = fid; + if (this.authorized.get(connection.identity) !== fid) throw new Error("attestation does not authorize this FID"); + connection.fid = fid; + socket.send(wire.ok(requestId)); + this.sendState(connection); + } else { + if (connection.fid === undefined) throw new Error("verify the notification reader first"); + const seenAt = Number(decoded.seenAt); + call.fid = connection.fid; call.seenAt = seenAt; + if (!Number.isSafeInteger(seenAt) || seenAt < 0 || seenAt > Math.floor(Date.now() / 1000)) throw new Error("invalid seen time"); + const next = Math.max(this.seen(connection.fid), seenAt); + this.states.set(connection.fid, next); + this.history.push({ fid: connection.fid, seenAt: next }); + socket.send(wire.ok(requestId)); + for (const subscriber of this.connections) if (subscriber.fid === connection.fid) this.sendState(subscriber); + } + } catch (error) { socket.send(wire.error(requestId, String(error))); } + } else if (message.tag === "Unsubscribe") { + // This app disconnects its private connection on account changes. + connection.querySetId = undefined; + } else { throw new Error(`Unexpected client protocol message ${message.tag}`); } + }); + socket.send(wire.initial(connection.identity, token, this.connections.length)); + }); + } + async disconnect(label: string) { + this.offline.add(label); + for (const connection of this.connections.filter(c => c.label === label && c.active)) { + connection.active = false; + await connection.socket.close({ code: 1013, reason: "Fixture offline" }); + } + } +} + +function fidFrom(cookie: string) { + const raw = /(?:^|;\s*)__Host-allthing-user=([^;]*)/.exec(cookie)?.[1]; + try { return raw ? Number(JSON.parse(decodeURIComponent(raw)).fid) : 0; } catch { return 0; } +} + +async function reader(browser: Browser, peer: Peer, label: string, who: typeof alice | null, options: { clock?: boolean; holdAttestation?: number; configured?: boolean } = {}) { + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + if (options.clock) await page.clock.install(); + let releaseAttestation!: () => void; + const held = new Promise(resolve => { releaseAttestation = resolve; }); + const state = { newest: at - 300, calls: 0, status: 0, attestations: [] as number[], finishedAttestations: [] as number[] }; + await page.route("**/api/v1/**", route => route.fulfill({ status: 404, json: { error: "Fixture endpoint" } })); + await page.route("**/api/v1/status", route => { + state.status++; + return options.configured === false + ? route.fulfill({ status: 404, json: { error: "Sync is not configured" } }) + : route.fulfill({ json: { spacetime: { attest: true } } }); + }); + await page.route("**/api/v1/home**", route => route.fulfill({ json: { pool: [], notes: {}, meta: meta() } })); + await page.route("**/api/v1/notifications**", async route => { + const fid = fidFrom(await route.request().headerValue("cookie") ?? ""); + state.calls++; + return route.fulfill({ json: payload(fid, state.newest), headers: { "cache-control": "private, no-store" } }); + }); + await page.route("**/api/v1/auth/attest", async route => { + const fid = fidFrom(await route.request().headerValue("cookie") ?? ""); + state.attestations.push(fid); + if (fid === options.holdAttestation) await held; + const { identity } = route.request().postDataJSON(); + peer.authorized.set(identity, fid); + await route.fulfill({ json: { identity, fid, expiry: Math.floor(Date.now() / 1000) + 600, signature: "11".repeat(64) } }); + state.finishedAttestations.push(fid); + }); + await peer.attach(page, label); + if (who && options.holdAttestation) { + await page.addInitScript(user => { + if (sessionStorage.getItem("notification-fixture-initial-signin")) return; + sessionStorage.setItem("notification-fixture-initial-signin", "1"); + document.cookie = `__Host-allthing-user=${encodeURIComponent(JSON.stringify(user))}; Path=/; Secure; SameSite=Lax`; + }, who); + } else if (who) await signIn(page, who); + return { context, page, state, errors, releaseAttestation }; +} + +const bell = (page: Page) => page.getByRole("link", { name: /^Notifications, \d+ unread$/ }); +const rows = (page: Page) => page.locator(".notif-list > .notif:not(.skeleton)"); +const localSeen = (page: Page, fid = alice.fid) => page.evaluate(({ key, fid }) => JSON.parse(localStorage.getItem(key) ?? "{}")[fid] ?? 0, { key: SEEN, fid }); +async function noMessageKeys(pages: Page[]) { + for (const page of pages) { + // A notification visit must not initialize Messages' separate key store. + const databases = await page.evaluate(async () => (await indexedDB.databases()).map(db => db.name)); + expect(databases).not.toContain("allthing-stdb"); + } +} +async function noMessageSideEffects(peer: Peer, pages: Page[]) { + expect(peer.queries.length).toBeGreaterThan(0); + expect(peer.queries.every(query => /^\s*select\s+\*\s+from\s+my_notification_state\s*;?\s*$/i.test(query))).toBe(true); + expect(peer.calls.every(call => ["verify_notification_fid", "mark_notifications_seen"].includes(call.name))).toBe(true); + await noMessageKeys(pages); +} +async function close(...readers: { context: BrowserContext }[]) { await Promise.all(readers.map(reader => reader.context.close())); } +async function diagnose(peer: Peer, ...readers: Awaited>[]) { + const pages = await Promise.all(readers.map(async reader => ({ + requests: reader.state, errors: reader.errors, + document: await reader.page.evaluate(key => ({ + boot: Boolean(document.querySelector("#boot")), app: Boolean(document.querySelector("#app")), + bell: document.querySelector(".bell")?.getAttribute("aria-label"), seen: localStorage.getItem(key), + bridge: Boolean((globalThis as { allthingStdb?: unknown }).allthingStdb), + }), SEEN).catch(() => ({ unavailable: true })), + }))); + await test.info().attach("notification-sync-diagnostics", { contentType: "application/json", body: JSON.stringify({ + pages, queries: peer.queries, history: peer.history, tokenExchanges: peer.tokenExchanges, + calls: peer.calls.map(({ label, name, fid, seenAt }) => ({ label, name, fid, seenAt })), + connections: peer.connections.map(({ label, active, fid, querySetId }) => ({ label, active, fid, subscribed: querySetId !== undefined })), + }, null, 2) }); +} + +// Contexts are deliberately independent: no shared storageState, localStorage, +// cookies or BroadcastChannel can explain their synchronized badges. +test("reading notifications clears another session and a fresh session hydrates the prior read baseline", async ({ browser }) => { + const peer = new Peer(); + const a = await reader(browser, peer, "a", alice), b = await reader(browser, peer, "b", alice); + let fresh: Awaited> | undefined; + try { + await Promise.all([a.page.goto("/"), b.page.goto("/")]); + await expect.poll(() => peer.verified("a").length + peer.verified("b").length).toBe(2); + await expect(bell(a.page)).toHaveAccessibleName("Notifications, 2 unread"); + await expect(bell(b.page)).toHaveAccessibleName("Notifications, 2 unread"); + expect(peer.verified("a")[0].identity).not.toBe(peer.verified("b")[0].identity); + await a.page.goto("/notifications"); + await expect(rows(a.page)).toHaveCount(2); + await expect(bell(b.page)).toHaveAccessibleName("Notifications, 0 unread"); + await expect.poll(() => localSeen(b.page)).toBe(a.state.newest); + expect(b.state.calls).toBe(1); + // Clearing another session's bell does not change this visit's row tint. + await expect(a.page.locator(".notif.new")).toHaveCount(2); + fresh = await reader(browser, peer, "fresh", alice); + await fresh.page.goto("/notifications"); + await expect.poll(() => peer.verified("fresh").length).toBe(1); + await expect(rows(fresh.page)).toHaveCount(2); + await expect(bell(fresh.page)).toHaveAccessibleName("Notifications, 0 unread"); + await expect(fresh.page.locator(".notif.new")).toHaveCount(0); + await noMessageSideEffects(peer, [a.page, b.page, fresh.page]); + expect([...a.errors, ...b.errors, ...fresh.errors]).toEqual([]); + } catch (error) { await diagnose(peer, a, b, ...(fresh ? [fresh] : [])); throw error; } + finally { await close(a, b, ...(fresh ? [fresh] : [])); } +}); + +test("a later notification becomes unread after a remote read without an extra read on the sync event", async ({ browser }) => { + const peer = new Peer(); + const a = await reader(browser, peer, "a", alice), b = await reader(browser, peer, "b", alice, { clock: true }); + try { + await Promise.all([a.page.goto("/notifications"), b.page.goto("/")]); + await expect.poll(() => peer.seen(alice.fid)).toBe(a.state.newest); + await expect(bell(b.page)).toHaveAccessibleName("Notifications, 0 unread"); + expect(b.state.calls).toBe(1); + b.state.newest += 120; + await b.page.clock.fastForward("01:01"); + await b.page.evaluate(() => window.dispatchEvent(new Event("focus"))); + await expect(bell(b.page)).toHaveAccessibleName("Notifications, 1 unread"); + expect(b.state.calls).toBe(2); + expect(peer.seen(alice.fid)).toBe(a.state.newest); + await b.page.goto("/notifications?tab=replies"); + await expect(rows(b.page)).toHaveCount(1); + await expect.poll(() => peer.seen(alice.fid)).toBe(b.state.newest); + await expect.poll(() => localSeen(a.page)).toBe(b.state.newest); + await expect(a.page.locator(".notif.new")).toHaveCount(2); + await expect(b.page.getByRole("tab", { name: "Replies", exact: true })).toHaveAttribute("aria-selected", "true"); + await noMessageSideEffects(peer, [a.page, b.page]); + expect([...a.errors, ...b.errors]).toEqual([]); + } catch (error) { await diagnose(peer, a, b); throw error; } + finally { await close(a, b); } +}); + +test("offline local reads reconcile with a newer remote watermark without rolling it back", async ({ browser }) => { + const peer = new Peer(); + const a = await reader(browser, peer, "a", alice), b = await reader(browser, peer, "b", alice); + try { + await Promise.all([a.page.goto("/"), b.page.goto("/")]); + await expect.poll(() => peer.verified("a").length + peer.verified("b").length).toBe(2); + await peer.disconnect("a"); + await a.page.goto("/notifications"); + await expect(rows(a.page)).toHaveCount(2); + await expect.poll(() => localSeen(a.page)).toBe(a.state.newest); + expect(peer.seen(alice.fid)).toBe(0); + b.state.newest += 120; + // A new document discards the old one-minute in-memory source cache. + await b.page.goto("/notifications"); + await expect.poll(() => peer.seen(alice.fid)).toBe(b.state.newest); + peer.offline.delete("a"); + await a.page.evaluate(() => { window.dispatchEvent(new Event("online")); window.dispatchEvent(new Event("focus")); }); + await expect.poll(() => peer.verified("a").length).toBe(1); + await expect.poll(() => localSeen(a.page)).toBe(b.state.newest); + expect(peer.seen(alice.fid)).toBe(b.state.newest); + const history = peer.history.filter(row => row.fid === alice.fid).map(row => row.seenAt); + expect(history.every((seen, i) => i === 0 || seen >= history[i - 1])).toBe(true); + await a.page.reload(); + await expect(rows(a.page)).toHaveCount(2); + await expect(bell(a.page)).toHaveAccessibleName("Notifications, 0 unread"); + await expect(a.page.locator(".notif.new")).toHaveCount(0); + await noMessageSideEffects(peer, [a.page, b.page]); + expect([...a.errors, ...b.errors]).toEqual([]); + } catch (error) { await diagnose(peer, a, b); throw error; } + finally { await close(a, b); } +}); + +test("sign-out and an account switch fence an old attestation and keep FID read state isolated", async ({ browser }) => { + const peer = new Peer(); peer.states.set(alice.fid, at - 180); + const a = await reader(browser, peer, "switch", alice, { holdAttestation: alice.fid }); + const b = await reader(browser, peer, "bob", bob); + try { + await Promise.all([a.page.goto("/"), b.page.goto("/")]); + await expect.poll(() => a.state.attestations.includes(alice.fid)).toBe(true); + await expect.poll(() => peer.verified("bob").length).toBe(1); + await expect(bell(b.page)).toHaveAccessibleName("Notifications, 2 unread"); + await a.page.evaluate(user => { + document.cookie = `__Host-allthing-user=${encodeURIComponent(JSON.stringify(user))}; Path=/; Secure; SameSite=Lax`; + new BroadcastChannel("allthing-session").postMessage("changed"); + }, bob); + await expect.poll(() => peer.verified("switch").some(c => c.fid === bob.fid)).toBe(true); + await expect(bell(a.page)).toHaveAccessibleName("Notifications, 2 unread"); + expect(await localSeen(a.page, bob.fid)).toBe(0); + await a.page.evaluate(() => { + document.cookie = "__Host-allthing-user=; Path=/; Max-Age=0; Secure; SameSite=Lax"; + new BroadcastChannel("allthing-session").postMessage("changed"); + }); + await expect(a.page.locator(".bell")).toHaveCount(0); + a.releaseAttestation(); + await expect.poll(() => a.state.finishedAttestations.includes(alice.fid)).toBe(true); + await a.page.goto("/notifications"); + await expect(a.page.getByText("Sign in to see your notifications.")).toBeVisible(); + await expect(a.page.locator(".bell")).toHaveCount(0); + expect(peer.seen(bob.fid)).toBe(0); + expect(peer.seen(alice.fid)).toBe(at - 180); + expect(peer.calls.filter(call => call.label === "switch" && call.name === "verify_notification_fid" && call.fid === alice.fid)).toEqual([]); + expect(peer.calls.filter(call => call.label === "switch" && call.name === "mark_notifications_seen")).toEqual([]); + await expect(bell(b.page)).toHaveAccessibleName("Notifications, 2 unread"); + await noMessageSideEffects(peer, [a.page, b.page]); + expect([...a.errors, ...b.errors]).toEqual([]); + } catch (error) { await diagnose(peer, a, b); throw error; } + finally { a.releaseAttestation(); await close(a, b); } +}); + +test("signed-out pages make no notification-sync connection, attestation or message keys", async ({ browser }) => { + const peer = new Peer(); + const a = await reader(browser, peer, "signed-out", null); + let unconfigured: Awaited> | undefined; + try { + await a.page.goto("/notifications"); + await appCame(a.page); + await expect(a.page.getByText("Sign in to see your notifications.")).toBeVisible(); + expect(peer.connections).toHaveLength(0); + expect(a.state.attestations).toEqual([]); + expect(a.state.calls).toBe(0); + expect(a.state.status).toBe(0); + await noMessageKeys([a.page]); + expect(a.errors).toEqual([]); + // A signed-in browser on an unconfigured deployment still reads locally; + // the status probe must stop before SDK loading, token storage or sockets. + unconfigured = await reader(browser, peer, "unconfigured", alice, { configured: false }); + await unconfigured.page.goto("/notifications"); + await expect(rows(unconfigured.page)).toHaveCount(2); + await expect(bell(unconfigured.page)).toHaveAccessibleName("Notifications, 0 unread"); + await expect.poll(() => unconfigured!.state.status).toBe(1); + expect(peer.connections).toHaveLength(0); + expect(unconfigured.state.attestations).toEqual([]); + expect(unconfigured.state.calls).toBe(1); + await expect(unconfigured.page.locator("script[data-stdb]")).toHaveCount(0); + await noMessageKeys([unconfigured.page]); + expect(await unconfigured.page.evaluate(async () => (await indexedDB.databases()).map(db => db.name))).not.toContain("allthing-notification-sync"); + expect(unconfigured.errors).toEqual([]); + } catch (error) { await diagnose(peer, a, ...(unconfigured ? [unconfigured] : [])); throw error; } + finally { await close(a, ...(unconfigured ? [unconfigured] : [])); } +}); diff --git a/spacetime/HANDOFF.md b/spacetime/HANDOFF.md index bdacd98d..75de4de3 100644 --- a/spacetime/HANDOFF.md +++ b/spacetime/HANDOFF.md @@ -4,8 +4,7 @@ Module: `spacetime/spacetimedb` in this repository (Rust, spacetimedb 2.13). Builds with `spacetime build`; from the repository root, `cargo xtask module-check` runs its isolated checks (CI's `SpacetimeDB module` job). `sh spacetime/check.sh` is a thin compatibility wrapper. The native command clears workspace `RUSTFLAGS` and retains rustfmt, native/Wasm Clippy, unit tests, the release Wasm build and the license audit when `cargo-deny` is installed (required by CI). Target DB name: **`allthing`** (fall back to `allthing-chat` if the name is taken) on maincloud. URL once published: `https://maincloud.spacetimedb.com` / ws, database `allthing`. Dashboard: https://spacetimedb.com/allthing -STATUS: published to maincloud as `allthing` (checked 21 October 2026: `GET /v1/database/allthing` answers, and its schema has -these tables and reducers, with `general` seeded as room 1). +STATUS: the existing `allthing` database was confirmed on 21 October 2026, with `general` seeded as room 1. That historical check predates the notification-state additions below and does not confirm their deployment; publish the updated module and verify its schema/reducers before announcing sync live. ## Publish ``` @@ -19,14 +18,17 @@ spacetime generate --lang typescript --out-dir ../web/stdb/module_bindings --mod Public: `user(identity PK, fid idx, verified, online, last_seen)`; `user_key(identity PK, fid, x25519_pub[32], key_version, updated_at)`; `room(id, name unique, created_by, created_at)` (`general` is seeded as id 1); `chat_message(id, room_id idx, sender, sender_fid, text, sent_at)`; `typing(identity PK, room_id, expires_at)`. -Private: `dm_message(id, sender idx, recipient idx, sender_key_version, recipient_key_version, nonce[24], ciphertext, sent_at)`, `rate_limit`. -Per-user views: `my_dm_inbox` and `my_dm_outbox` show the caller's DMs only. That rule is enforced on the server. A test confirmed that `dm_message` cannot be queried by clients. +Private: `dm_message(id, sender idx, recipient idx, sender_key_version, recipient_key_version, nonce[24], ciphertext, sent_at)`, `rate_limit`, +`notification_reader(identity PK, fid)` and `notification_state(fid PK, seen_at)` (Unix seconds; no notification content). +Per-user views: `my_dm_inbox` and `my_dm_outbox` show the caller's DMs only. That rule is enforced on the server. A test confirmed that `dm_message` cannot be queried by clients. `my_notification_state` returns zero or one row for the FID bound to the calling identity by `notification_reader`; unverified identities and public chat claims grant no access. ## Reducers `register(fid)`, `set_public_key(x25519_pub)` (bumps key_version), `create_room(name)` ([a-z0-9-_], 1–64 chars), `send_message(room_id, text)` (1–4000 bytes; also clears typing), `set_typing(room_id, bool)` (6 s TTL; also cleared on disconnect), `send_dm(recipient, sender_kv, recipient_kv, nonce, ciphertext)` (24-byte nonce, 16 B–16 KiB ciphertext, recipient must have a key), -`delete_dm(id)` (sender or recipient). Rate limit: 1 write per 250 ms per identity. Presence is tracked through `user.online`, set on connect/disconnect. +`delete_dm(id)` (sender or recipient). Chat writes are limited to 1 per 250 ms per identity. Presence is tracked through `user.online`, set on connect/disconnect. + +Notification reducers are separate: `verify_notification_fid(fid, expiry, signature)` requires the same pinned, identity-bound Worker attestation even when chat's development fallback is unverified; it locks a nonzero FID to this notification identity without creating/updating a public chat user, key or presence. `mark_notifications_seen(seen_at)` derives the FID from that private binding and takes the maximum with its existing watermark; stale/equal values do not rewrite it. Negative times and times over 60 seconds ahead of the server are rejected; accepted near-future values clamp to server time. These reducers do not use chat's send-rate limiter. ## E2E design (the server sees only ciphertext) - The client generates an X25519 keypair. The private key stays in IndexedDB (non-extractable where possible), and only the public key goes to `set_public_key`. @@ -47,7 +49,13 @@ Per-user views: `my_dm_inbox` and `my_dm_outbox` show the caller's DMs only. Tha The UI and encryption logic are Rust. `web/stdb/bridge.ts` is the narrow adapter to the official TypeScript SDK; generated bindings live in `web/stdb/module_bindings`. The Rust SDK is not substituted because its BSL-1.1 license fails the repository's current license policy. `cargo xtask dist` bundles the adapter through the installed esbuild CLI and checks the pinned SDK version; the CSP codec adapter remains covered by wire-format and browser tests. 1. Use `npm ci` for the pinned SDK and regenerate bindings from the Rust module when its schema changes (see above). -2. The Messages-only adapter connects to `wss://maincloud.spacetimedb.com`, database `allthing`, keeping its per-account token in IndexedDB. +2. The shared adapter connects to `wss://maincloud.spacetimedb.com`, database `allthing`. Messages keeps its per-account token and X25519 key in IndexedDB `allthing-stdb`, store `identities`; it does not reuse notification credentials. 3. After Farcaster sign-in, call `register`, obtain the Worker's identity-bound attestation, call `verify_fid`, then `set_public_key`. 4. Subscribe to `room`, `chat_message WHERE room_id=…`, `typing`, `user`, `user_key`, `my_dm_inbox`, `my_dm_outbox`. 5. Throttle `set_typing` (~every 3 s while typing). DMs are encrypted/decrypted entirely on the client. + +### Notification-only client + +`notification_sync.rs` uses the same SDK module script through `stdb_bridge`, but a separate connection and per-FID `{identity, token}` record in IndexedDB `allthing-notification-sync`, store `identities` (no DM key). After its delayed/API-quiet/browser-idle status capability check, it obtains a fresh Worker attestation, calls `verify_notification_fid` and subscribes only to `SELECT * FROM my_notification_state`. It never calls `register` or `set_public_key` or subscribes to public chat/presence tables. + +The existing localStorage notification watermark is the offline/legacy cache. Rust `ReadState` merges it with the initial private snapshot before uploading local progress, retains failed writes, and takes maxima on acknowledgements/remote updates without echoing them. One pending maximum is written at a time, coalesced after 200 ms; 30-second operation deadlines, retry backoff capped at five minutes and online/focus/visibility wakeups handle disconnects. Sign-out/account-generation changes fence stale responses and detach the old socket. The page reconciles its initial remote baseline once; later remote reads update its badge/cache while preserving that visit's row tint. Tab selection remains in the URL. diff --git a/spacetime/spacetimedb/src/lib.rs b/spacetime/spacetimedb/src/lib.rs index 9320925c..e665b3bd 100644 --- a/spacetime/spacetimedb/src/lib.rs +++ b/spacetime/spacetimedb/src/lib.rs @@ -18,6 +18,9 @@ pub const ATTEST_MAX_TTL_SECS: u64 = 3600; /// the key might ever sign. Allthing's Worker builds the same string /// (`allthing_core::stdb::attestation_message`). pub const ATTEST_CONTEXT: &str = "allthing-spacetimedb-fid-v1"; +/// Small upstream clock differences may put an event just ahead of this +/// server. Accepted values are still capped at server time before storing. +const NOTIFICATION_FUTURE_ALLOWANCE_SECS: i64 = 60; /// SpacetimeDB identity -> Farcaster FID. `verified` once `verify_fid` checked /// Allthing's attestation that this identity signed in as `fid`. @@ -101,6 +104,34 @@ pub struct RateLimit { pub last_at: Timestamp, } +/// PRIVATE: an identity's signed account binding for notification state. +/// It is separate from public chat users: reading notifications must not +/// announce presence, publish a message key or create a chat account. +#[spacetimedb::table(accessor = notification_reader)] +pub struct NotificationReader { + #[primary_key] + pub identity: Identity, + pub fid: u64, +} + +/// PRIVATE: the account-wide notification watermark, shared by all of its +/// independently verified identities. No notification content is stored. +#[spacetimedb::table(accessor = notification_state)] +pub struct NotificationState { + #[primary_key] + pub fid: u64, + /// Unix seconds, matching Hypersnap's notification event timestamps. + pub seen_at: i64, +} + +/// Only this caller's signed account binding can select a watermark. A +/// client-supplied FID or a public, unverified chat claim grants no access. +#[view(accessor = my_notification_state, public)] +fn my_notification_state(ctx: &ViewContext) -> Vec { + let Some(reader) = ctx.db.notification_reader().identity().find(ctx.sender()) else { return Vec::new() }; + ctx.db.notification_state().fid().find(reader.fid).into_iter().collect() +} + #[view(accessor = my_dm_inbox, public)] fn my_dm_inbox(ctx: &ViewContext) -> Vec { ctx.db.dm_message().recipient().filter(ctx.sender()).collect() @@ -203,6 +234,46 @@ pub fn verify_fid(ctx: &ReducerContext, fid: u64, expiry: u64, signature: Vec) -> Result<(), String> { + let now = (ctx.timestamp.to_micros_since_unix_epoch() / 1_000_000).max(0) as u64; + let identity = ctx.sender().to_hex().to_string(); + check_attestation(&ATTEST_PUBLIC_KEY, &identity, fid, expiry, now, &signature)?; + let readers = ctx.db.notification_reader(); + let previous = readers.identity().find(ctx.sender()); + notification_fid(previous.as_ref().map(|reader| reader.fid), fid)?; + if previous.is_none() { + readers.insert(NotificationReader { identity: ctx.sender(), fid }); + } + Ok(()) +} + +/// Advance the signed account's watermark. Stale or offline sessions may +/// replay older values safely: a transaction can only increase the value. +/// This deliberately does not share chat's per-identity send rate limiter. +#[spacetimedb::reducer] +pub fn mark_notifications_seen(ctx: &ReducerContext, seen_at: i64) -> Result<(), String> { + let reader = + ctx.db.notification_reader().identity().find(ctx.sender()).ok_or("verify your Farcaster account first")?; + let now = ctx.timestamp.to_micros_since_unix_epoch() / 1_000_000; + let states = ctx.db.notification_state(); + let old = states.fid().find(reader.fid); + let merged = notification_seen_at(old.as_ref().map(|state| state.seen_at), seen_at, now)?; + match old { + Some(state) if merged > state.seen_at => { + states.fid().update(NotificationState { fid: reader.fid, seen_at: merged }); + } + None => { + states.insert(NotificationState { fid: reader.fid, seen_at: merged }); + } + _ => {} + } + Ok(()) +} + #[spacetimedb::reducer] pub fn set_public_key(ctx: &ReducerContext, x25519_pub: Vec) -> Result<(), String> { let u = speaker(ctx)?; @@ -317,6 +388,28 @@ pub fn delete_dm(ctx: &ReducerContext, id: u64) -> Result<(), String> { // Checks on what clients send, kept apart from the database so they can be // unit-tested natively (`cargo test` in this directory). +/// Notification bindings are immutable and never accept a zero FID, even +/// if called apart from attestation validation in a future reducer. +fn notification_fid(previous: Option, fid: u64) -> Result<(), String> { + if fid == 0 { + return Err("bad fid".into()); + } + if previous.is_some_and(|old| old != fid) { + return Err("notification fid locked (verified)".into()); + } + Ok(()) +} + +/// Validate a proposed watermark before taking the monotonic maximum. +/// A client's bad clock cannot permanently hide future notifications. +fn notification_seen_at(previous: Option, seen_at: i64, now: i64) -> Result { + let now = now.max(0); + if seen_at < 0 || seen_at > now.saturating_add(NOTIFICATION_FUTURE_ALLOWANCE_SECS) { + return Err("notification time is outside the allowed range".into()); + } + Ok(previous.unwrap_or(0).max(seen_at.min(now))) +} + /// A room name: trimmed, lower-cased, 1–64 of `[a-z0-9-_]`. pub fn room_name(name: &str) -> Result { let name = name.trim().to_lowercase(); @@ -452,6 +545,44 @@ mod tests { assert!(!attestations_on(&[0; 32])); } + #[test] + fn notification_accounts_cannot_be_rebound() { + assert_eq!(notification_fid(None, 3), Ok(())); + assert_eq!(notification_fid(Some(3), 3), Ok(())); + assert!(notification_fid(None, 0).is_err()); + assert!(notification_fid(Some(3), 0).is_err()); + assert!(notification_fid(Some(3), 4).is_err()); + } + + #[test] + fn notification_watermarks_merge_all_session_orders() { + let now = 1_800_000_000; + assert_eq!(notification_seen_at(None, 0, now), Ok(0)); + assert_eq!(notification_seen_at(None, now - 10, now), Ok(now - 10)); + assert_eq!(notification_seen_at(Some(now - 5), now - 10, now), Ok(now - 5)); + assert_eq!(notification_seen_at(Some(now - 10), now - 5, now), Ok(now - 5)); + assert_eq!(notification_seen_at(Some(now - 5), now - 5, now), Ok(now - 5)); + for values in [[now - 10, now - 5, now - 20], [now - 20, now - 5, now - 10]] { + let mut seen = None; + for value in values { + seen = Some(notification_seen_at(seen, value, now).unwrap()); + } + assert_eq!(seen, Some(now - 5)); + } + } + + #[test] + fn notification_watermarks_never_persist_a_future_clock() { + let now = 1_800_000_000; + assert_eq!(notification_seen_at(None, now, now), Ok(now)); + assert_eq!(notification_seen_at(None, now + NOTIFICATION_FUTURE_ALLOWANCE_SECS, now), Ok(now)); + assert!(notification_seen_at(None, now + NOTIFICATION_FUTURE_ALLOWANCE_SECS + 1, now).is_err()); + assert!(notification_seen_at(Some(now), -1, now).is_err()); + assert!(notification_seen_at(None, i64::MAX, now).is_err()); + assert_eq!(notification_seen_at(None, 0, -1), Ok(0)); + assert_eq!(notification_seen_at(None, i64::MAX, i64::MAX), Ok(i64::MAX)); + } + #[test] fn room_names() { assert_eq!(room_name(" General ").unwrap(), "general"); diff --git a/web/stdb/bridge.ts b/web/stdb/bridge.ts index 03d1c842..1034efb8 100644 --- a/web/stdb/bridge.ts +++ b/web/stdb/bridge.ts @@ -8,8 +8,8 @@ // AGPL and its license audit don't take; the TypeScript SDK is ISC. // // The native xtask bundles this through esbuild into one file, `assets/stdb-.js`, -// which the app adds as a module script only when a signed-in reader opens -// /messages: no other page fetches it. It sets +// which the app adds once for Messages or eligible signed-in notification +// read-state sync. The separate connections share the script. It sets // `window.allthingStdb`, and the app talks to it in JSON strings, so the // app's own module needs no glue for it. // @@ -52,6 +52,7 @@ const TABLES: Record = { typing: "typing", myDmInbox: "my_dm_inbox", myDmOutbox: "my_dm_outbox", + myNotificationState: "my_notification_state", }; function table(conn: Any, accessor: string): Any { @@ -88,11 +89,11 @@ class Bridge { for (const [accessor, name] of Object.entries(TABLES)) { const t = table(conn, accessor); if (!t) continue; - t.onInsert((_ctx: Any, row: Any) => emit(JSON.stringify({ t: "insert", table: name, row: plain(row) }))); - t.onDelete((_ctx: Any, row: Any) => emit(JSON.stringify({ t: "delete", table: name, row: plain(row) }))); + t.onInsert((_ctx: Any, row: Any) => this.emit(JSON.stringify({ t: "insert", table: name, row: plain(row) }))); + t.onDelete((_ctx: Any, row: Any) => this.emit(JSON.stringify({ t: "delete", table: name, row: plain(row) }))); if (typeof t.onUpdate === "function") { t.onUpdate((_ctx: Any, old: Any, row: Any) => - emit(JSON.stringify({ t: "update", table: name, old: plain(old), row: plain(row) })), + this.emit(JSON.stringify({ t: "update", table: name, old: plain(old), row: plain(row) })), ); } } @@ -120,6 +121,16 @@ class Bridge { ); } + verifyNotificationFid(fid: number, expiry: number, signatureHex: string) { + return settle(() => + reducer(this.conn, "verifyNotificationFid")({ fid: BigInt(fid), expiry: BigInt(expiry), signature: unhex(signatureHex) }), + ); + } + + markNotificationsSeen(seenAt: number) { + return settle(() => reducer(this.conn, "markNotificationsSeen")({ seenAt: BigInt(seenAt) })); + } + setPublicKey(publicKeyHex: string) { return settle(() => reducer(this.conn, "setPublicKey")({ x25519Pub: unhex(publicKeyHex) })); } @@ -150,6 +161,8 @@ class Bridge { } disconnect() { + // Release the caller's Wasm closure before an asynchronous close event. + this.emit = () => {}; this.conn.disconnect(); } } @@ -160,6 +173,7 @@ class Bridge { * and `disconnected`; `connected` resolves with the identity and token. */ function connect(uri: string, database: string, token: string, emit: Emit): Bridge { + let bridge: Bridge | undefined; let settle: { resolve: (v: string) => void; reject: (e: Error) => void } | undefined; const connected = new Promise((resolve, reject) => (settle = { resolve, reject })); const conn = DbConnection.builder() @@ -170,11 +184,15 @@ function connect(uri: string, database: string, token: string, emit: Emit): Brid settle?.resolve(JSON.stringify({ identity: identity.toHexString(), token: newToken })), ) .onConnectError((_ctx: Any, error: Any) => settle?.reject(new Error(String(error?.message ?? error)))) - .onDisconnect((_ctx: Any, error: Any) => - emit(JSON.stringify({ t: "disconnected", error: error ? String(error?.message ?? error) : null })), - ) + .onDisconnect((_ctx: Any, error: Any) => { + // A socket can close before InitialConnection arrives. Wake callers + // awaiting connected immediately; rejecting an already settled promise + // is harmless for established connections. + settle?.reject(new Error(String(error?.message ?? error ?? "Disconnected from the database"))); + bridge?.emit(JSON.stringify({ t: "disconnected", error: error ? String(error?.message ?? error) : null })); + }) .build(); - const bridge = new Bridge(conn, emit); + bridge = new Bridge(conn, emit); bridge.connected = connected; return bridge; } diff --git a/web/stdb/module_bindings/index.ts b/web/stdb/module_bindings/index.ts index 7f0f31ba..5be8726d 100644 --- a/web/stdb/module_bindings/index.ts +++ b/web/stdb/module_bindings/index.ts @@ -36,12 +36,14 @@ import { // Import all reducer arg schemas import CreateRoomReducer from "./create_room_reducer"; import DeleteDmReducer from "./delete_dm_reducer"; +import MarkNotificationsSeenReducer from "./mark_notifications_seen_reducer"; import RegisterReducer from "./register_reducer"; import SendDmReducer from "./send_dm_reducer"; import SendMessageReducer from "./send_message_reducer"; import SetPublicKeyReducer from "./set_public_key_reducer"; import SetTypingReducer from "./set_typing_reducer"; import VerifyFidReducer from "./verify_fid_reducer"; +import VerifyNotificationFidReducer from "./verify_notification_fid_reducer"; // Import all procedure arg schemas @@ -49,6 +51,7 @@ import VerifyFidReducer from "./verify_fid_reducer"; import ChatMessageRow from "./chat_message_table"; import MyDmInboxRow from "./my_dm_inbox_table"; import MyDmOutboxRow from "./my_dm_outbox_table"; +import MyNotificationStateRow from "./my_notification_state_table"; import RoomRow from "./room_table"; import TypingRow from "./typing_table"; import UserRow from "./user_table"; @@ -140,18 +143,27 @@ const tablesSchema = __schema({ constraints: [ ], }, MyDmOutboxRow), + myNotificationState: __table({ + name: 'my_notification_state', + indexes: [ + ], + constraints: [ + ], + }, MyNotificationStateRow), }); /** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ const reducersSchema = __reducers( __reducerSchema("create_room", CreateRoomReducer), __reducerSchema("delete_dm", DeleteDmReducer), + __reducerSchema("mark_notifications_seen", MarkNotificationsSeenReducer), __reducerSchema("register", RegisterReducer), __reducerSchema("send_dm", SendDmReducer), __reducerSchema("send_message", SendMessageReducer), __reducerSchema("set_public_key", SetPublicKeyReducer), __reducerSchema("set_typing", SetTypingReducer), __reducerSchema("verify_fid", VerifyFidReducer), + __reducerSchema("verify_notification_fid", VerifyNotificationFidReducer), ); /** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ @@ -168,6 +180,8 @@ type __SchemaWithTableAccessorAliases = Omit & { readonly accessorName: "my_dm_inbox" }; /** @deprecated Use `myDmOutbox` instead. This alias will be removed in the next major version. */ readonly "my_dm_outbox": Omit & { readonly accessorName: "my_dm_outbox" }; + /** @deprecated Use `myNotificationState` instead. This alias will be removed in the next major version. */ + readonly "my_notification_state": Omit & { readonly accessorName: "my_notification_state" }; }; }; @@ -190,6 +204,7 @@ const tableAccessorAliases = { "user_key": "userKey", "my_dm_inbox": "myDmInbox", "my_dm_outbox": "myDmOutbox", + "my_notification_state": "myNotificationState", } as const; function __withTableAccessorAliases(target: T, freeze = false): T { @@ -218,6 +233,8 @@ export type DbView = __DbViewBase & { readonly "my_dm_inbox": __DbViewBase["myDmInbox"]; /** @deprecated Use `myDmOutbox` instead. This alias will be removed in the next major version. */ readonly "my_dm_outbox": __DbViewBase["myDmOutbox"]; + /** @deprecated Use `myNotificationState` instead. This alias will be removed in the next major version. */ + readonly "my_notification_state": __DbViewBase["myNotificationState"]; }; type __TablesBase = __QueryBuilder; @@ -230,6 +247,8 @@ export type Tables = __TablesBase & { readonly "my_dm_inbox": __TablesBase["myDmInbox"]; /** @deprecated Use `myDmOutbox` instead. This alias will be removed in the next major version. */ readonly "my_dm_outbox": __TablesBase["myDmOutbox"]; + /** @deprecated Use `myNotificationState` instead. This alias will be removed in the next major version. */ + readonly "my_notification_state": __TablesBase["myNotificationState"]; }; /** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ diff --git a/web/stdb/module_bindings/mark_notifications_seen_reducer.ts b/web/stdb/module_bindings/mark_notifications_seen_reducer.ts new file mode 100644 index 00000000..1d1cc980 --- /dev/null +++ b/web/stdb/module_bindings/mark_notifications_seen_reducer.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + seenAt: __t.i64(), +}; diff --git a/web/stdb/module_bindings/my_notification_state_table.ts b/web/stdb/module_bindings/my_notification_state_table.ts new file mode 100644 index 00000000..cc33d726 --- /dev/null +++ b/web/stdb/module_bindings/my_notification_state_table.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + fid: __t.u64(), + seenAt: __t.i64().name("seen_at"), +}); diff --git a/web/stdb/module_bindings/types.ts b/web/stdb/module_bindings/types.ts index ec728e84..143d9957 100644 --- a/web/stdb/module_bindings/types.ts +++ b/web/stdb/module_bindings/types.ts @@ -32,6 +32,18 @@ export const DmMessage = __t.object("DmMessage", { }); export type DmMessage = __Infer; +export const NotificationReader = __t.object("NotificationReader", { + identity: __t.identity(), + fid: __t.u64(), +}); +export type NotificationReader = __Infer; + +export const NotificationState = __t.object("NotificationState", { + fid: __t.u64(), + seenAt: __t.i64(), +}); +export type NotificationState = __Infer; + export const RateLimit = __t.object("RateLimit", { identity: __t.identity(), lastAt: __t.timestamp(), diff --git a/web/stdb/module_bindings/types/reducers.ts b/web/stdb/module_bindings/types/reducers.ts index f141e6a5..77e28ba3 100644 --- a/web/stdb/module_bindings/types/reducers.ts +++ b/web/stdb/module_bindings/types/reducers.ts @@ -8,19 +8,23 @@ import { type Infer as __Infer } from "spacetimedb"; // Import all reducer arg schemas import CreateRoomReducer from "../create_room_reducer"; import DeleteDmReducer from "../delete_dm_reducer"; +import MarkNotificationsSeenReducer from "../mark_notifications_seen_reducer"; import RegisterReducer from "../register_reducer"; import SendDmReducer from "../send_dm_reducer"; import SendMessageReducer from "../send_message_reducer"; import SetPublicKeyReducer from "../set_public_key_reducer"; import SetTypingReducer from "../set_typing_reducer"; import VerifyFidReducer from "../verify_fid_reducer"; +import VerifyNotificationFidReducer from "../verify_notification_fid_reducer"; export type CreateRoomParams = __Infer; export type DeleteDmParams = __Infer; +export type MarkNotificationsSeenParams = __Infer; export type RegisterParams = __Infer; export type SendDmParams = __Infer; export type SendMessageParams = __Infer; export type SetPublicKeyParams = __Infer; export type SetTypingParams = __Infer; export type VerifyFidParams = __Infer; +export type VerifyNotificationFidParams = __Infer; diff --git a/web/stdb/module_bindings/verify_notification_fid_reducer.ts b/web/stdb/module_bindings/verify_notification_fid_reducer.ts new file mode 100644 index 00000000..93cb4f49 --- /dev/null +++ b/web/stdb/module_bindings/verify_notification_fid_reducer.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + fid: __t.u64(), + expiry: __t.u64(), + signature: __t.byteArray(), +};