From 7e11154302d527d715e415187d6457cd299d25bf Mon Sep 17 00:00:00 2001 From: Timmy Date: Fri, 21 Aug 2026 13:55:49 +0000 Subject: [PATCH] feat: add private subpage staging slice --- app.js | 41 ++++--- package.json | 2 +- server.mjs | 49 ++++++-- service-worker.js | 55 ++++++++- styles.css | 1 + tests/service-worker-runtime.test.js | 97 ++++++++++++++++ tests/sleek-chat.acceptance.mjs | 72 +++++++++++- tests/staging-health.test.js | 167 +++++++++++++++++++++++++++ 8 files changed, 454 insertions(+), 30 deletions(-) create mode 100644 tests/service-worker-runtime.test.js create mode 100644 tests/staging-health.test.js diff --git a/app.js b/app.js index 5ff3af6..5419eed 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,21 @@ import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.js'; -const STORE = 'timmy-ledger-v1'; +const runtimeConfig = window.__TIMMY_CONFIG__ || { basePath: '/', stagingLabel: '' }; +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`; +const LEGACY_STORE = 'timmy-ledger-v1'; +if (BASE_PATH === '/') { + const legacyEntries = localStorage.getItem(LEGACY_STORE); + if (legacyEntries !== null) { + try { + if (!localStorage.getItem(STORE)) localStorage.setItem(STORE, legacyEntries); + localStorage.removeItem(LEGACY_STORE); + } catch {} + } +} const app = document.querySelector('#app'); let entries = loadEntries(); let view = 'home'; @@ -24,7 +38,8 @@ function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'sh function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); } function shell(content) { - app.innerHTML = `
Timmy mascot
Timmyprivate bowel journal
● Ledger local
${content}${nav()}`; + const label=runtimeConfig.stagingLabel?``:''; + app.innerHTML = `
Timmy mascot
Timmyprivate bowel journal
● Ledger local
${content}${label}${nav()}`; bindGlobal(); } function nav(){return ``} @@ -35,7 +50,7 @@ function bindGlobal(){ } function recentList(limit=5){ - if(!entries.length)return `

Quiet bowl, clean slate.

Your first log takes about ten seconds.

`; + if(!entries.length)return `

Quiet bowl, clean slate.

Your first log takes about ten seconds.

`; return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`
T${e.bristolType}
${formatDate(e.occurredAt)}${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}
${bucketForBristolType(e.bristolType)}
`).join(''); } function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()e.occurredAt.slice(0 function home(){ const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0]; - shell(`
Your intelligent pooping pal

Log it.
Learn the pattern.

Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.

${thisWeek()}this week
${currentStreak()}day streak
${entries.length}all logs
Timmy noticed

${esc(buildTimmySummary(entries))}

Latest

${latest?'Recent log':'Ready when you are'}

${latest?'':''}
${latest?`
T${latest.bristolType}
${formatDate(latest.occurredAt)}${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}
`:'

One quick, confirmed entry is enough to begin seeing your pattern.

'}
`); + shell(`
Your intelligent pooping pal

Log it.
Learn the pattern.

Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.

${thisWeek()}this week
${currentStreak()}day streak
${entries.length}all logs
Timmy noticed

${esc(buildTimmySummary(entries))}

Latest

${latest?'Recent log':'Ready when you are'}

${latest?'':''}
${latest?`
T${latest.bristolType}
${formatDate(latest.occurredAt)}${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}
`:'

One quick, confirmed entry is enough to begin seeing your pattern.

'}
`); } function calendar(){ @@ -69,13 +84,13 @@ function timmy(){ requestAnimationFrame(()=>{const chat=document.querySelector('#chat');if(chat)chat.scrollTop=chat.scrollHeight}); } async function loadAgentStatus(){ - try{const response=await fetch('/api/agent/status',{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}} + try{const response=await fetch(appPath('api/agent/status'),{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}} catch{agentStatus={enabled:false,configured:false,authenticated:false,mode:'local-fallback'}} if(view==='timmy')timmy(); } async function unlockAgent(){ const code=document.querySelector('#agent-code')?.value||'';chatError=''; - try{const response=await fetch('/api/agent/unlock',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()} + try{const response=await fetch(appPath('api/agent/unlock'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()} catch(error){chatError=error.message||'Could not connect.';timmy()} } function localChatReply(message){ @@ -91,7 +106,7 @@ async function sendChat(event){ const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return} if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return} chatBusy=true;timmy(); - try{const response=await fetch('/api/agent/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})} + try{const response=await fetch(appPath('api/agent/chat'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})} catch(error){chatError=error.message||'Hermes is unavailable.';chatMessages.push({role:'timmy',text:'I could not reach Hermes. Your local journal still works, and nothing was changed.'})} finally{chatBusy=false;timmy()} } @@ -102,11 +117,11 @@ 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(){ - try{const response=await fetch('/api/vision-status',{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}} + try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}} catch{visionStatus={enabled:false,providerReady:false}} if(document.querySelector('.scan-sheet')&&['pick','ready'].includes(photoFirstMode))showPhotoFirst(photoFirstMode) } @@ -117,9 +132,9 @@ function visionStatusHtml(){ return '
○ Vision worker offline · manual logging is still available
'; } function photoFirstBody(mode,error=''){ - if(mode==='pick')return `${visionStatusHtml()}
Timmy

One photo. Two useful suggestions.

Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.

`; + if(mode==='pick')return `${visionStatusHtml()}
Timmy

One photo. Two useful suggestions.

Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.

`; if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}Photo awaiting AI analysis

${esc(photoHint)}

`;} - if(mode==='analyzing')return `
Timmy

Timmy is looking at form and color…

Not symptoms. Not disease. Not whether Taco Bell was a strategic error.

`; + if(mode==='analyzing')return `
Timmy

Timmy is looking at form and color…

Not symptoms. Not disease. Not whether Taco Bell was a strategic error.

`; if(mode==='error')return `

Timmy couldn’t analyze that safely.

${esc(error||'Continue manually or try a clearer photo.')}

`; if(aiSuggestion?.status==='suggestion')return `
AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE
BRISTOL FORMType ${aiSuggestion.bristolType}
VISIBLE COLOR${esc(aiSuggestion.color)}

${esc(aiSuggestion.observations||'Visual match found.')}

${esc(aiSuggestion.warning)}

`; return `
?

No confident match.

${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}

`; @@ -134,7 +149,7 @@ function showPhotoFirst(mode='pick',error=''){ document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)}); } async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}} -async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch('/api/analyze',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}} +async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}} function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)} function showLogStep(step){ @@ -159,4 +174,4 @@ function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=san function render(){({home,calendar,timmy,privacy}[view]||home)()} render(); -if('serviceWorker' in navigator)navigator.serviceWorker.register('/service-worker.js').catch(()=>{}); +if('serviceWorker' in navigator)navigator.serviceWorker.register(appPath('service-worker.js'),{scope:APP_ROOT}).catch(()=>{}); diff --git a/package.json b/package.json index 02346d3..50fc64d 100644 --- a/package.json +++ b/package.json @@ -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/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", diff --git a/server.mjs b/server.mjs index a1171ae..bdac8e9 100644 --- a/server.mjs +++ b/server.mjs @@ -1,4 +1,5 @@ import http from 'node:http'; +import { isIP } from 'node:net'; import { readFile, stat } from 'node:fs/promises'; import { extname, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,46 +9,74 @@ import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } const root=fileURLToPath(new URL('.',import.meta.url)); const port=Number(process.env.PORT||4173); +const host=process.env.HOST||'0.0.0.0'; +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||!/^\/[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); const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'}; const visionConfig=resolveVisionConfig(process.env); const agentConfig=resolveHermesAgentConfig(process.env); const agentService=createHermesAgentService({config:agentConfig}); +const release=/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(process.env.TIMMY_RELEASE_TAG||'')?process.env.TIMMY_RELEASE_TAG:'development'; +const commit=/^[0-9a-f]{12,40}$/.test(process.env.TIMMY_RELEASE_COMMIT||'')?process.env.TIMMY_RELEASE_COMMIT:'000000000000'; +const publicBasePath=basePath||'/'; +const appRoot=basePath?`${basePath}/`:'/'; +const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:''; function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));} function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})} function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''} function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')} function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')} -function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${secure}`} +function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`} function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})} http.createServer(async(req,res)=>{ try{ const url=new URL(req.url,'http://localhost'); - if(url.pathname==='/api/vision-status'&&req.method==='GET'){ + if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')} + if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()} + const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname; + if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled}); + if(appPath==='/api/vision-status'&&req.method==='GET'){ const provider=await probeVisionProvider(visionConfig); const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.'; return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy}); } - if(url.pathname==='/api/analyze'&&req.method==='POST'){ + if(appPath==='/api/analyze'&&req.method==='POST'){ if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); const payload=await readJson(req); try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))} catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})} } - if(url.pathname==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent'))); - if(url.pathname==='/api/agent/unlock'&&req.method==='POST'){ + if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent'))); + if(appPath==='/api/agent/unlock'&&req.method==='POST'){ try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)} } - if(url.pathname==='/api/agent/chat'&&req.method==='POST'){ + if(appPath==='/api/agent/chat'&&req.method==='POST'){ try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)} } - if(url.pathname.startsWith('/api/'))return sendJson(res,404,{error:'Not found'}); + if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'}); if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()} - const pathname=decodeURIComponent(url.pathname); + const pathname=decodeURIComponent(appPath); let path=normalize(join(root,pathname==='/'?'index.html':pathname)); if(!path.startsWith(root))throw new Error('bad path'); const info=await stat(path);if(info.isDirectory())path=join(path,'index.html'); - const body=await readFile(path);res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body); + let body=await readFile(path); + if(appPath==='/'||appPath==='/index.html'){ + const browserConfig=JSON.stringify({basePath:publicBasePath,stagingLabel}).replace(/',`\n `).replace('\n