Compare commits
1 Commits
bac335a681
...
7e11154302
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e11154302 |
12
app.js
12
app.js
|
|
@ -6,10 +6,14 @@ const BASE_PATH = runtimeConfig.basePath || '/';
|
||||||
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
|
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
|
||||||
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
|
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
|
||||||
const STORE = `timmy:${BASE_PATH}:ledger-v1`;
|
const STORE = `timmy:${BASE_PATH}:ledger-v1`;
|
||||||
if (BASE_PATH === '/' && !localStorage.getItem(STORE)) {
|
const LEGACY_STORE = 'timmy-ledger-v1';
|
||||||
const legacyEntries = localStorage.getItem('timmy-ledger-v1');
|
if (BASE_PATH === '/') {
|
||||||
|
const legacyEntries = localStorage.getItem(LEGACY_STORE);
|
||||||
if (legacyEntries !== null) {
|
if (legacyEntries !== null) {
|
||||||
try { localStorage.setItem(STORE, legacyEntries); localStorage.removeItem('timmy-ledger-v1'); } catch {}
|
try {
|
||||||
|
if (!localStorage.getItem(STORE)) localStorage.setItem(STORE, legacyEntries);
|
||||||
|
localStorage.removeItem(LEGACY_STORE);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
|
|
@ -113,7 +117,7 @@ function privacy(){
|
||||||
}
|
}
|
||||||
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
|
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
|
||||||
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
|
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
|
||||||
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);render();toast('Local ledger deleted')}}
|
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
|
||||||
|
|
||||||
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
||||||
async function loadVisionStatus(){
|
async function loadVisionStatus(){
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js",
|
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js",
|
||||||
"test:ui": "node tests/ui.acceptance.mjs",
|
"test:ui": "node tests/ui.acceptance.mjs",
|
||||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||||
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,9 @@ if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.');
|
||||||
function resolveBasePath(value) {
|
function resolveBasePath(value) {
|
||||||
const raw=String(value||'');
|
const raw=String(value||'');
|
||||||
if(!raw||raw==='/')return '';
|
if(!raw||raw==='/')return '';
|
||||||
if(raw.length>128||!raw.startsWith('/')||/[?#\\]/.test(raw)||/%(?:2f|5c)/i.test(raw))throw new Error('TIMMY_BASE_PATH must be a safe absolute URL path.');
|
if(raw.length>128||!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*\/?$/.test(raw))throw new Error('TIMMY_BASE_PATH must be a canonical absolute URL path using plain unreserved characters.');
|
||||||
let normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
|
const normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
|
||||||
if(normalized.includes('//'))throw new Error('TIMMY_BASE_PATH must not contain empty path segments.');
|
if(normalized.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
|
||||||
let decoded;
|
|
||||||
try{decoded=decodeURIComponent(normalized)}catch{throw new Error('TIMMY_BASE_PATH contains malformed encoding.')}
|
|
||||||
if(decoded.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);
|
const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,31 @@ self.addEventListener('install', event => event.waitUntil(
|
||||||
));
|
));
|
||||||
self.addEventListener('activate', event => event.waitUntil(
|
self.addEventListener('activate', event => event.waitUntil(
|
||||||
caches.keys()
|
caches.keys()
|
||||||
.then(keys => Promise.all(keys.filter(key => key.startsWith(CACHE_NAMESPACE) && key !== CACHE).map(key => caches.delete(key))))
|
.then(keys => Promise.all(keys.filter(key =>
|
||||||
|
(key.startsWith(CACHE_NAMESPACE) && key !== CACHE)
|
||||||
|
|| (ROOT === '/' && key === 'timmy-shell-v4')
|
||||||
|
).map(key => caches.delete(key))))
|
||||||
.then(() => self.clients.claim()),
|
.then(() => self.clients.claim()),
|
||||||
));
|
));
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
if (event.request.method !== 'GET' || !new URL(event.request.url).pathname.startsWith(ROOT)) return;
|
if (event.request.method !== 'GET') return;
|
||||||
|
const pathname = new URL(event.request.url).pathname;
|
||||||
|
if (!pathname.startsWith(ROOT)) return;
|
||||||
|
if (pathname.startsWith(appPath('api/'))) {
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
fetch(event.request)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
const copy = response.clone();
|
if (!/\bno-store\b/i.test(response.headers.get('cache-control') || '')) {
|
||||||
caches.open(CACHE).then(cache => cache.put(event.request, copy));
|
const copy = response.clone();
|
||||||
|
caches.open(CACHE).then(cache => cache.put(event.request, copy));
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => caches.match(event.request).then(hit => hit || caches.match(appPath('index.html')))),
|
.catch(() => caches.open(CACHE).then(async cache =>
|
||||||
|
(await cache.match(event.request)) || cache.match(appPath('index.html'))
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
97
tests/service-worker-runtime.test.js
Normal file
97
tests/service-worker-runtime.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = await readFile(new URL('../service-worker.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
function loadWorker({ scope = 'https://example.test/', cacheKeys = [], currentHits = new Map(), fetchImpl = async () => { throw new Error('offline'); } } = {}) {
|
||||||
|
const listeners = new Map();
|
||||||
|
const deleted = [];
|
||||||
|
const puts = [];
|
||||||
|
let cacheOpenCount = 0;
|
||||||
|
const currentCache = {
|
||||||
|
addAll: async () => {},
|
||||||
|
put: async request => { puts.push(typeof request === 'string' ? request : request.url); },
|
||||||
|
match: async request => currentHits.get(typeof request === 'string' ? request : request.url),
|
||||||
|
};
|
||||||
|
const caches = {
|
||||||
|
keys: async () => cacheKeys,
|
||||||
|
delete: async key => { deleted.push(key); return true; },
|
||||||
|
open: async () => { cacheOpenCount += 1; return currentCache; },
|
||||||
|
match: async () => { throw new Error('global cache matching must not be used'); },
|
||||||
|
};
|
||||||
|
const self = {
|
||||||
|
registration: { scope },
|
||||||
|
addEventListener: (type, handler) => listeners.set(type, handler),
|
||||||
|
skipWaiting: async () => {},
|
||||||
|
clients: { claim: async () => {} },
|
||||||
|
};
|
||||||
|
vm.runInNewContext(source, { self, caches, fetch: fetchImpl, URL, Promise, String });
|
||||||
|
return { listeners, deleted, puts, getCacheOpenCount: () => cacheOpenCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchExtendable(handler) {
|
||||||
|
let pending;
|
||||||
|
handler({ waitUntil(value) { pending = value; } });
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchFetch(handler, request) {
|
||||||
|
let response;
|
||||||
|
handler({ request, respondWith(value) { response = value; } });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => {
|
||||||
|
const { listeners, deleted } = loadWorker({
|
||||||
|
cacheKeys: [
|
||||||
|
'timmy-shell-v4',
|
||||||
|
'timmy-shell:/:v4',
|
||||||
|
'timmy-shell:/:v5',
|
||||||
|
'timmy-shell:/timmy-staging/:v4',
|
||||||
|
'sibling-shared-origin-cache',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchExtendable(listeners.get('activate'));
|
||||||
|
|
||||||
|
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline shell lookup uses only the current named cache', async () => {
|
||||||
|
const request = { method: 'GET', url: 'https://example.test/app.js' };
|
||||||
|
const current = { body: 'current app' };
|
||||||
|
const { listeners } = loadWorker({ currentHits: new Map([[request.url, current]]) });
|
||||||
|
|
||||||
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||||
|
|
||||||
|
assert.equal(await response, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scoped API GET requests bypass the shell cache entirely', async () => {
|
||||||
|
const request = { method: 'GET', url: 'https://example.test/timmy-staging/api/healthz' };
|
||||||
|
const networkResponse = { headers: { get: () => 'no-store' }, clone: () => ({}) };
|
||||||
|
const { listeners, puts, getCacheOpenCount } = loadWorker({
|
||||||
|
scope: 'https://example.test/timmy-staging/',
|
||||||
|
fetchImpl: async () => networkResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||||
|
|
||||||
|
assert.equal(await response, networkResponse);
|
||||||
|
assert.equal(getCacheOpenCount(), 0);
|
||||||
|
assert.deepEqual(puts, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-store shell responses are returned without being cached', async () => {
|
||||||
|
const request = { method: 'GET', url: 'https://example.test/styles.css' };
|
||||||
|
const networkResponse = { headers: { get: () => 'private, no-store' }, clone: () => ({}) };
|
||||||
|
const { listeners, puts } = loadWorker({ fetchImpl: async () => networkResponse });
|
||||||
|
|
||||||
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||||
|
await response;
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(puts, []);
|
||||||
|
});
|
||||||
|
|
@ -83,21 +83,35 @@ assert.ok(apiRequests.every(path => path.startsWith(`${expectedRoot}api/`)), `AP
|
||||||
|
|
||||||
if (expectedBasePath === '/') {
|
if (expectedBasePath === '/') {
|
||||||
const migrationContext = await browser.newContext({ serviceWorkers: 'block' });
|
const migrationContext = await browser.newContext({ serviceWorkers: 'block' });
|
||||||
await migrationContext.addInitScript(() => localStorage.setItem('timmy-ledger-v1', JSON.stringify([{
|
await migrationContext.addInitScript(() => {
|
||||||
id: 'legacy-root-entry',
|
const entry = id => ({
|
||||||
occurredAt: new Date().toISOString(),
|
id,
|
||||||
bristolType: 4,
|
occurredAt: new Date().toISOString(),
|
||||||
color: 'brown',
|
bristolType: 4,
|
||||||
urgency: 0,
|
color: 'brown',
|
||||||
discomfort: 0,
|
urgency: 0,
|
||||||
note: '',
|
discomfort: 0,
|
||||||
symptoms: {},
|
note: '',
|
||||||
}])));
|
symptoms: {},
|
||||||
|
});
|
||||||
|
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([entry('current-root-entry')]));
|
||||||
|
localStorage.setItem('timmy-ledger-v1', JSON.stringify([entry('legacy-root-entry')]));
|
||||||
|
localStorage.setItem('unrelated-sibling-data', 'preserve-me');
|
||||||
|
});
|
||||||
const migrationPage = await migrationContext.newPage();
|
const migrationPage = await migrationContext.newPage();
|
||||||
await migrationPage.goto(appUrl);
|
await migrationPage.goto(appUrl);
|
||||||
assert.equal(await migrationPage.locator('.glance div').last().locator('strong').innerText(), '1');
|
assert.equal(await migrationPage.locator('.glance div').last().locator('strong').innerText(), '1');
|
||||||
|
assert.match(await migrationPage.evaluate(() => localStorage.getItem('timmy:/:ledger-v1')), /current-root-entry/);
|
||||||
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
|
||||||
assert.ok(await migrationPage.evaluate(() => localStorage.getItem('timmy:/:ledger-v1')));
|
|
||||||
|
await migrationPage.evaluate(() => localStorage.setItem('timmy-ledger-v1', '[{"id":"resurrection-risk"}]'));
|
||||||
|
await migrationPage.getByRole('button', { name: 'Journal', exact: true }).click();
|
||||||
|
await migrationPage.locator('[data-view="privacy"]').click();
|
||||||
|
migrationPage.once('dialog', dialog => dialog.accept());
|
||||||
|
await migrationPage.locator('#delete-all').click();
|
||||||
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy:/:ledger-v1')), null);
|
||||||
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
|
||||||
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('unrelated-sibling-data')), 'preserve-me');
|
||||||
await migrationContext.close();
|
await migrationContext.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,14 +129,23 @@ test('malformed and escaping base paths are rejected at startup', async () => {
|
||||||
const invalid = [
|
const invalid = [
|
||||||
'timmy-staging',
|
'timmy-staging',
|
||||||
'/../git',
|
'/../git',
|
||||||
|
'/timmy/./nested',
|
||||||
'/%2e%2e/git',
|
'/%2e%2e/git',
|
||||||
|
'/%252e%252e/git',
|
||||||
'/timmy%2Fgit',
|
'/timmy%2Fgit',
|
||||||
'/timmy%5cgit',
|
'/timmy%5cgit',
|
||||||
|
'/timmy%2dstaging',
|
||||||
'/timmy\\git',
|
'/timmy\\git',
|
||||||
'/timmy?debug=1',
|
'/timmy?debug=1',
|
||||||
'/timmy#fragment',
|
'/timmy#fragment',
|
||||||
'/timmy%',
|
'/timmy%',
|
||||||
'/timmy//nested',
|
'/timmy//nested',
|
||||||
|
'/timmy-staging//',
|
||||||
|
'/timmy-staging;Secure',
|
||||||
|
'/timmy-staging"quoted',
|
||||||
|
"/timmy-staging'quoted",
|
||||||
|
'/timmy staging',
|
||||||
|
'/timmy\nstaging',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const value of invalid) {
|
for (const value of invalid) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user