Back to feed

sledtools/pika branch #33

pika-git-auth-1

Persist forge auth across tabs

Target branch: master

Merge Commit: 199facff9692639c008efd17a032523ded0ff632

branch: merged tutorial: ready ci: success
Open CI Details

Continuous Integration

CI: success

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #39 success

2 passed

head fa247adb4743cd3984695543605805ac429c45b1 · queued 2026-03-24 14:38:50 · 2 lane(s)

queued 9s · ran 24s

check-notifications · success check-agent-contracts · success

Summary

This branch migrates the pika-news browser authentication layer from sessionStorage to localStorage so that forge auth tokens persist across browser tabs and normal navigation. The base template introduces a thin accessor abstraction over localStorage, a cross-tab synchronisation listener via the Storage event, and helper functions (getAuthValue, setAuthValue, removeAuthValue, readAuthSnapshot). Fallback sessionStorage references in the admin and inbox templates are removed in favour of the centralised pikaAuth.clear() path. A new Rust test asserts the invariants: base.html must reference localStorage and the storage event listener, and no template may contain direct sessionStorage calls for auth keys.

Tutorial Steps

Replace sessionStorage with localStorage in the auth bootstrap

Intent: Switch the storage backend from sessionStorage (scoped to a single tab) to localStorage (shared across all same-origin tabs) so that authentication survives opening new tabs and normal page navigation.

Affected files: crates/pika-news/templates/base.html

Evidence
@@ -95,18 +95,41 @@
-        let authToken = sessionStorage.getItem('pika_news_token');
-        let savedNpub = sessionStorage.getItem('pika_news_npub');
-        let savedIsAdmin = sessionStorage.getItem('pika_news_is_admin') === '1';
-        let savedCanForgeWrite = sessionStorage.getItem('pika_news_can_forge_write') === '1';
+        const authStorage = window.localStorage;
+
+        function getAuthValue(key) {
+          return authStorage.getItem(key);
+        }
+
+        function setAuthValue(key, value) {
+          authStorage.setItem(key, value);
+        }
+
+        function removeAuthValue(key) {
+          authStorage.removeItem(key);
+        }
+
+        function readAuthSnapshot() {
+          return {
+            token: getAuthValue('pika_news_token'),
+            npub: getAuthValue('pika_news_npub'),
+            isAdmin: getAuthValue('pika_news_is_admin') === '1',
+            canForgeWrite: getAuthValue('pika_news_can_forge_write') === '1',
+          };
+        }

The core change lives at the top of the auth bootstrap block in base.html. A single const authStorage = window.localStorage binding replaces every previous bare sessionStorage reference. Four thin wrapper functions are introduced:

FunctionPurpose
getAuthValue(key)Read a key from localStorage
setAuthValue(key, value)Write a key to localStorage
removeAuthValue(key)Delete a key from localStorage
readAuthSnapshot()Return an object with all four auth fields at once

All downstream call-sites in base.html — the initial state read, window.pikaAuth accessor methods, clearAuth(), fetchInboxCount(), refreshSessionInfo(), and the login flow — are rewritten to use these wrappers instead of calling sessionStorage directly. This means a future change to the storage backend (e.g., cookie-backed) only requires editing one line.

Add cross-tab auth synchronisation via the Storage event

Intent: React to auth changes made in other tabs so that every open pika-news tab stays in sync without requiring a manual reload.

Affected files: crates/pika-news/templates/base.html

Evidence
@@ -239,6 +262,17 @@
+        window.addEventListener('storage', function (event) {
+          if (!event.key || !event.key.startsWith('pika_news_')) return;
+          const snapshot = readAuthSnapshot();
+          if (snapshot.token && snapshot.npub) {
+            showAuthed(snapshot.npub, snapshot.isAdmin, snapshot.canForgeWrite);
+            refreshSessionInfo();
+          } else {
+            clearAuth();
+          }
+        });

A storage event listener is registered on window. The browser fires this event in every other tab when localStorage is modified, making it the standard mechanism for cross-tab communication.

The handler:

  1. Ignores events for keys that don't start with pika_news_ to avoid reacting to unrelated localStorage writes.
  2. Reads a full auth snapshot via readAuthSnapshot().
  3. If a valid token and npub exist, calls showAuthed() and refreshSessionInfo() to update the UI and revalidate the session with the server.
  4. Otherwise calls clearAuth() to log the user out in the current tab.

This means signing in on Tab A immediately updates Tab B's navbar, and signing out in any tab propagates the logout everywhere.

Remove sessionStorage fallbacks from admin and inbox templates

Intent: Eliminate redundant fallback branches that directly called sessionStorage, ensuring all auth clearing goes through the centralised pikaAuth.clear() path.

Affected files: crates/pika-news/templates/admin.html, crates/pika-news/templates/inbox.html

Evidence
@@ -165,11 +165,6 @@
         function clearAuth() {
           if (window.pikaAuth && window.pikaAuth.clear) {
             window.pikaAuth.clear();
-          } else {
-            sessionStorage.removeItem('pika_news_token');
-            sessionStorage.removeItem('pika_news_npub');
-            sessionStorage.removeItem('pika_news_is_admin');
-            sessionStorage.removeItem('pika_news_can_forge_write');
           }
@@ -77,10 +77,6 @@
         function clearAuth() {
           if (window.pikaAuth && window.pikaAuth.clear) {
             window.pikaAuth.clear();
-          } else {
-            sessionStorage.removeItem('pika_news_token');
-            sessionStorage.removeItem('pika_news_npub');
-            sessionStorage.removeItem('pika_news_is_admin');
           }

Both admin.html and inbox.html contained local clearAuth() functions with an else branch that called sessionStorage.removeItem directly as a fallback in case window.pikaAuth was not yet initialised.

These branches are deleted because:

  • base.html always defines window.pikaAuth before any page-specific script runs (it's in the shared layout).
  • The fallback would now be incorrect — it would write to sessionStorage while the real auth lives in localStorage, creating a ghost state.

After this change, admin.html and inbox.html contain zero direct storage API calls for auth keys.

Add a Rust test asserting the localStorage migration invariants

Intent: Prevent regressions by statically checking that templates use localStorage, listen for the storage event, and contain no residual sessionStorage references for auth keys.

Affected files: crates/pika-news/src/web.rs

Evidence
@@ -4882,6 +4882,21 @@
+    #[test]
+    fn browser_auth_bootstrap_uses_local_storage_across_tabs() {
+        let base_template = include_str!("../templates/base.html");
+        let inbox_template = include_str!("../templates/inbox.html");
+        let admin_template = include_str!("../templates/admin.html");
+
+        assert!(base_template.contains("const authStorage = window.localStorage;"));
+        assert!(base_template.contains("window.addEventListener('storage'"));
+        assert!(!base_template.contains("sessionStorage.getItem('pika_news_token')"));
+        assert!(!base_template.contains("sessionStorage.setItem('pika_news_token'"));
+        assert!(!base_template.contains("sessionStorage.removeItem('pika_news_token'"));
+        assert!(!inbox_template.contains("sessionStorage.removeItem('pika_news_token'"));
+        assert!(!admin_template.contains("sessionStorage.removeItem('pika_news_token'"));
+    }

A compile-time test (browser_auth_bootstrap_uses_local_storage_across_tabs) is added to web.rs. It uses include_str! to embed the three template files at compile time and then makes seven assertions:

Positive checks (base.html must contain):

  • const authStorage = window.localStorage; — confirms the localStorage binding exists.
  • window.addEventListener('storage' — confirms the cross-tab listener is registered.

Negative checks (no template may contain):

  • sessionStorage.getItem('pika_news_token') in base.html
  • sessionStorage.setItem('pika_news_token') in base.html
  • sessionStorage.removeItem('pika_news_token') in base.html, inbox.html, and admin.html

Because include_str! is evaluated at compile time, the test runs with zero I/O overhead and will fail the build immediately if anyone reintroduces a sessionStorage call for the auth token.

Diff