diff --git a/cmd/librenotes/web/public/app.html b/cmd/librenotes/web/public/app.html
new file mode 100644
index 0000000..808d815
--- /dev/null
+++ b/cmd/librenotes/web/public/app.html
@@ -0,0 +1,28 @@
+
+
+
+
+ Welcome
+ You're signed in. The full notes UI ships in a later phase; this
+ page exists to verify the auth + tenant-context wiring end to end.
+
+
+
+
+
+
+
diff --git a/cmd/librenotes/web/public/app.js b/cmd/librenotes/web/public/app.js
new file mode 100644
index 0000000..dccc4e8
--- /dev/null
+++ b/cmd/librenotes/web/public/app.js
@@ -0,0 +1,44 @@
+// app.js — minimal authenticated landing inside the app shell.
+//
+// Demonstrates:
+// - apiFetch() automatically attaching Authorization (issue #14)
+// - 401 -> redirect-to-login behaviour (issue #14)
+// - tenantStore() for per-user UI state (issue #15)
+// - logout clearing both session and tenant store (issue #15)
+
+(function () {
+ "use strict";
+
+ if (!window.authClient.isAuthenticated()) {
+ window.location.replace("/login.html");
+ return;
+ }
+
+ const session = window.authClient.loadSession();
+ document.getElementById("who").textContent = session.email;
+
+ // Tenant-scoped UI state demo: theme preference.
+ const store = window.authClient.tenantStore();
+ const savedTheme = store.get("theme", "light");
+ document.body.dataset.theme = savedTheme;
+
+ document.getElementById("theme-toggle").addEventListener("click", function () {
+ const next = document.body.dataset.theme === "dark" ? "light" : "dark";
+ document.body.dataset.theme = next;
+ store.set("theme", next);
+ });
+
+ document.getElementById("logout").addEventListener("click", function () {
+ window.authClient.logout();
+ });
+
+ // Sanity check the auth wiring round-trip.
+ window.authClient.apiFetch("/api/whoami")
+ .then(function (resp) { return resp.json(); })
+ .then(function (data) {
+ document.getElementById("whoami-output").textContent = JSON.stringify(data, null, 2);
+ })
+ .catch(function (e) {
+ document.getElementById("whoami-output").textContent = "error: " + e.message;
+ });
+})();
diff --git a/cmd/librenotes/web/public/auth-client.js b/cmd/librenotes/web/public/auth-client.js
new file mode 100644
index 0000000..28739fc
--- /dev/null
+++ b/cmd/librenotes/web/public/auth-client.js
@@ -0,0 +1,147 @@
+// auth-client.js — JWT session handling and API wrapper.
+//
+// Storage strategy (per issue #14):
+// We use sessionStorage rather than localStorage. sessionStorage is
+// isolated per tab and cleared on tab close, which limits exposure
+// if the user shares a machine. We accept the XSS risk inherent in
+// any JS-readable token store; an httpOnly cookie would be stronger
+// but requires server-set cookies and CSRF protection that the
+// minimal /auth/verify response does not provide today.
+//
+// Tenant-scoped storage (per issue #15):
+// tenantStore() returns a wrapper whose keys are prefixed with
+// "librenotes:{user_id}:". On logout we clear every key with that
+// prefix.
+
+(function () {
+ "use strict";
+
+ const SESSION_KEY = "librenotes.session";
+
+ function saveSession(data) {
+ // data: { jwt, user_id, email, expires_at }
+ if (!data || !data.jwt || !data.user_id) {
+ throw new Error("auth-client: invalid session data");
+ }
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(data));
+ }
+
+ function loadSession() {
+ try {
+ const raw = sessionStorage.getItem(SESSION_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (!parsed.jwt || !parsed.user_id) return null;
+ if (parsed.expires_at && parsed.expires_at * 1000 < Date.now()) {
+ // Expired — caller should treat as logged out.
+ return null;
+ }
+ return parsed;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function clearSession() {
+ const session = loadSession();
+ sessionStorage.removeItem(SESSION_KEY);
+ if (session && session.user_id) {
+ clearTenantStore(session.user_id);
+ }
+ }
+
+ function isAuthenticated() {
+ return loadSession() !== null;
+ }
+
+ // Build the "Authorization: Bearer