mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
9b9754778f
Browser sign-in worked, but the settings panel kept offering "Sign in with
Cognitum" afterwards; only a hard reload showed the true state. The server was
correct throughout.
Root cause: `ui/sw.js` routed cache-first as its CATCH-ALL for every path
outside `/api/` and `/health/`. `/oauth/status` therefore had its first
(signed-out) response stored in the Cache API and replayed to the page forever.
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so the
`no-store, no-cache, must-revalidate` the server already sends could not prevent
it. A hard reload bypasses the service worker, which is why that alone appeared
to fix it, and why signing out re-poisoned the entry.
Fixes, in order of blast radius:
- `/oauth/` is never handled by the worker. Not network-first — that still
writes a copy, which would be replayed the moment the server is briefly
unreachable, silently reinstating a stale sign-in state.
- Cache-first is now an ALLOWLIST (navigations and static asset extensions)
rather than the catch-all. This is the underlying defect: any endpoint added
outside `/api/` was frozen on its first response. Unrecognised paths now go
to the network untouched.
- `CACHE_NAME` bumped to `ruview-v2`, so `activate` evicts the poisoned
`ruview-v1` from browsers that already ran the old worker. Verified: the
cache list went from `[ruview-v1]` to `[ruview-v2]` on update.
- Shell lookups use `ignoreSearch` and store a search-less key, so the
`?signed_in=<ms>` the callback redirects to does not mint a fresh, never-hit
cache entry per sign-in.
Also: re-check sign-in state on `pageshow` (bfcache restore, where no script
re-runs) and on `visibilitychange` (signed in or out in another tab). Opening
the panel alone is not sufficient — it may already be open.
Verification, in a real browser driven end-to-end:
- Reproduced with a valid session cookie: server returned `signed_in: true` to
curl while the page's own fetch got a cached `signed_in: false`, and the
request never appeared in the server log at all.
- Confirmed the cached entry existed: `caches.open('ruview-v1').match(
'/oauth/status')` returned the signed-out body.
- After the fix, on a NORMAL (not hard) reload the panel reads
"Signed in as <account> - sensing:read" with Sign out shown.
Tests: `ui/sw.test.mjs` loads the real `sw.js` with stubbed worker globals and
asserts the routing decision per path. 4 of its 10 tests fail against the
pre-fix worker and pass after; the other 6 pin pre-existing guards (non-GET,
websocket upgrade, cross-origin, API paths, static assets, navigation) and pass
in both, so they track behaviour rather than the rewrite.
CI: adds a `ui-tests` job. Nothing ran the UI JavaScript before, so both this
suite and the ADR-272 ws-ticket suite would have rotted unexecuted — which is
the same blind spot that let this defect ship.
Removes the temporary `/oauth/status` cookie logging and the panel console
diagnostic added while tracking this down.
Co-Authored-By: Ruflo & AQE
160 lines
5.5 KiB
JavaScript
160 lines
5.5 KiB
JavaScript
// RuView Service Worker - Offline caching for the dashboard shell
|
|
// Strategy: Network-first for API calls, Cache-first for static assets
|
|
|
|
// Bumped from v1: an older SW cached `/oauth/status` cache-first, so browsers
|
|
// that ran it hold a permanently signed-out answer. `activate` deletes every
|
|
// cache whose name is not CACHE_NAME, so bumping is what evicts it from clients
|
|
// already in the field. Bump again if a future change poisons the cache.
|
|
const CACHE_NAME = 'ruview-v2';
|
|
|
|
// Requests whose response depends on the caller's credentials. These must never
|
|
// be served from the Cache API.
|
|
//
|
|
// The Cache API is NOT the HTTP cache: it ignores `Cache-Control` completely, so
|
|
// the `no-store, no-cache, must-revalidate` the server already sends on
|
|
// `/oauth/status` has no effect here. A cached signed-out response was returned
|
|
// to the page forever, and only a hard reload — which bypasses the service
|
|
// worker entirely — showed the true state. (ADR-271.)
|
|
const NEVER_CACHE_PREFIXES = ['/oauth/'];
|
|
|
|
// What may be served cache-first. Previously cache-first was the *catch-all* for
|
|
// everything outside `/api/` and `/health/`, which meant any endpoint added at a
|
|
// new path was frozen on first response. An allowlist fails safe instead: an
|
|
// unrecognised path goes to the network untouched.
|
|
const STATIC_ASSET = /\.(?:js|mjs|css|html|json|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|map)$/i;
|
|
const SHELL_ASSETS = [
|
|
'/',
|
|
'/index.html',
|
|
'/style.css',
|
|
'/app.js',
|
|
'/config/api.config.js',
|
|
'/components/TabManager.js',
|
|
'/components/DashboardTab.js',
|
|
'/components/HardwareTab.js',
|
|
'/components/LiveDemoTab.js',
|
|
'/components/SensingTab.js',
|
|
'/components/PoseDetectionCanvas.js',
|
|
'/services/api.service.js',
|
|
'/services/websocket.service.js',
|
|
'/services/health.service.js',
|
|
'/services/sensing.service.js',
|
|
'/services/pose.service.js',
|
|
'/services/stream.service.js',
|
|
'/utils/backend-detector.js',
|
|
'/utils/keyboard-shortcuts.js',
|
|
'/utils/perf-monitor.js',
|
|
'/utils/toast.js',
|
|
'/utils/theme-toggle.js',
|
|
'/utils/command-palette.js',
|
|
'/utils/activity-log.js',
|
|
'/utils/data-export.js',
|
|
'/utils/fullscreen.js',
|
|
'/utils/connection-status.js',
|
|
'/utils/mobile-nav.js'
|
|
];
|
|
|
|
// Install - cache shell assets
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
|
// Don't fail install if some assets are missing (dev mode)
|
|
console.warn('[SW] Some assets failed to cache:', err);
|
|
});
|
|
})
|
|
);
|
|
self.skipWaiting();
|
|
});
|
|
|
|
// Activate - clean old caches
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) => {
|
|
return Promise.all(
|
|
keys
|
|
.filter((key) => key !== CACHE_NAME)
|
|
.map((key) => caches.delete(key))
|
|
);
|
|
})
|
|
);
|
|
self.clients.claim();
|
|
});
|
|
|
|
// Fetch - network-first for API, cache-first for static
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (request.method !== 'GET') return;
|
|
|
|
// Skip WebSocket upgrade requests
|
|
if (request.headers.get('Upgrade') === 'websocket') return;
|
|
|
|
// Skip cross-origin requests
|
|
if (url.origin !== self.location.origin) return;
|
|
|
|
// Credentialed endpoints: hands off entirely. Not networkFirst — that still
|
|
// writes a copy into the cache, which would be replayed the moment the server
|
|
// is briefly unreachable, silently reinstating a stale sign-in state.
|
|
if (NEVER_CACHE_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
|
|
|
|
// API calls: network-first with cache fallback
|
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
|
|
event.respondWith(networkFirst(request));
|
|
return;
|
|
}
|
|
|
|
// Static assets and the app shell: cache-first with network fallback.
|
|
if (request.mode === 'navigate' || STATIC_ASSET.test(url.pathname)) {
|
|
event.respondWith(cacheFirst(request));
|
|
}
|
|
|
|
// Anything else is left alone and goes to the network as normal.
|
|
});
|
|
|
|
async function cacheFirst(request) {
|
|
// `ignoreSearch` so the shell is a single entry. Sign-in redirects back to
|
|
// `/ui/?signed_in=<ms>`, which would otherwise mint a fresh cache entry per
|
|
// sign-in and never hit any of them again.
|
|
const cached = await caches.match(request, { ignoreSearch: true });
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(CACHE_NAME);
|
|
// Store under the search-less URL to match how it is looked up above.
|
|
const key = new URL(request.url);
|
|
key.search = '';
|
|
cache.put(key.toString(), response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
// Return offline fallback for HTML navigation
|
|
if (request.headers.get('Accept')?.includes('text/html')) {
|
|
const fallback = await caches.match('/index.html');
|
|
if (fallback) return fallback;
|
|
}
|
|
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
|
}
|
|
}
|
|
|
|
async function networkFirst(request) {
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(CACHE_NAME);
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
return new Response(JSON.stringify({ error: 'offline' }), {
|
|
status: 503,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
}
|