Compare commits

..

1 Commits

Author SHA1 Message Date
7e11154302 feat: add private subpage staging slice
All checks were successful
Quality gates / quality (pull_request) Successful in 1m43s
2026-08-21 14:12:17 +00:00
7 changed files with 161 additions and 27 deletions

12
app.js
View File

@ -6,10 +6,14 @@ const BASE_PATH = runtimeConfig.basePath || '/';
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
const STORE = `timmy:${BASE_PATH}:ledger-v1`;
if (BASE_PATH === '/' && !localStorage.getItem(STORE)) {
const legacyEntries = localStorage.getItem('timmy-ledger-v1');
const LEGACY_STORE = 'timmy-ledger-v1';
if (BASE_PATH === '/') {
const legacyEntries = localStorage.getItem(LEGACY_STORE);
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');
@ -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');}
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()}
async function loadVisionStatus(){

View File

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"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:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",

View File

@ -14,12 +14,9 @@ if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.');
function resolveBasePath(value) {
const raw=String(value||'');
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.');
let normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
if(normalized.includes('//'))throw new Error('TIMMY_BASE_PATH must not contain empty path 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.');
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.');
const normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
if(normalized.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
return normalized;
}
const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);

View File

@ -20,18 +20,31 @@ self.addEventListener('install', event => event.waitUntil(
));
self.addEventListener('activate', event => event.waitUntil(
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()),
));
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(
fetch(event.request)
.then(response => {
if (!/\bno-store\b/i.test(response.headers.get('cache-control') || '')) {
const copy = response.clone();
caches.open(CACHE).then(cache => cache.put(event.request, copy));
}
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'))
)),
);
});

View 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, []);
});

View File

@ -83,8 +83,9 @@ assert.ok(apiRequests.every(path => path.startsWith(`${expectedRoot}api/`)), `AP
if (expectedBasePath === '/') {
const migrationContext = await browser.newContext({ serviceWorkers: 'block' });
await migrationContext.addInitScript(() => localStorage.setItem('timmy-ledger-v1', JSON.stringify([{
id: 'legacy-root-entry',
await migrationContext.addInitScript(() => {
const entry = id => ({
id,
occurredAt: new Date().toISOString(),
bristolType: 4,
color: 'brown',
@ -92,12 +93,25 @@ if (expectedBasePath === '/') {
discomfort: 0,
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();
await migrationPage.goto(appUrl);
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.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();
}

View File

@ -129,14 +129,23 @@ test('malformed and escaping base paths are rejected at startup', async () => {
const invalid = [
'timmy-staging',
'/../git',
'/timmy/./nested',
'/%2e%2e/git',
'/%252e%252e/git',
'/timmy%2Fgit',
'/timmy%5cgit',
'/timmy%2dstaging',
'/timmy\\git',
'/timmy?debug=1',
'/timmy#fragment',
'/timmy%',
'/timmy//nested',
'/timmy-staging//',
'/timmy-staging;Secure',
'/timmy-staging"quoted',
"/timmy-staging'quoted",
'/timmy staging',
'/timmy\nstaging',
];
for (const value of invalid) {