Local work in progress, not reviewed and not part of any PR. Committed only so the nightly release can build from a clean main without discarding it.
184 lines
13 KiB
JavaScript
184 lines
13 KiB
JavaScript
import http from 'node:http';
|
||
import { isIP } from 'node:net';
|
||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||
import { readFile, stat } from 'node:fs/promises';
|
||
import { extname, join, normalize } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { analyzePhoto, visionFailureResponse } from './src/vision-service.js';
|
||
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
|
||
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
|
||
import { readJsonBody } from './src/http-body.js';
|
||
import { inspectImageBytes, IngressError } from './src/image-ingress.js';
|
||
import { createRateLimiter } from './src/rate-limit.js';
|
||
import { createModelMetrics } from './src/model-metrics.js';
|
||
import { visionReadiness } from './src/vision-readiness.js';
|
||
|
||
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 escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&','<':'<','>':'>','"':'"'}[character]));}
|
||
// Photo ingress (issue #16). Every limit here is decided from the request itself and fails before
|
||
// any provider call: a per-caller request budget, a deadline for a body that never arrives, a byte
|
||
// ceiling, and a container/pixel inspection of the decoded image. The bucket key is a salted hash
|
||
// that lives only in this process's memory, so no address and no image byte is ever logged.
|
||
const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000);
|
||
const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)});
|
||
// Issue #19: operator health counters. The token is what makes this an operator surface rather than
|
||
// a public one — with no token configured the route does not exist at all.
|
||
const metricsToken=String(process.env.TIMMY_METRICS_TOKEN||'');
|
||
const modelMetrics=createModelMetrics({limit:Number(process.env.TIMMY_MODEL_CONCURRENCY||4)});
|
||
function classifyFailure(error){
|
||
const code=String(error?.code||'');
|
||
if(code==='invalid_json'||code==='invalid_fields')return 'schema_invalid';
|
||
const message=String(error?.message||'');
|
||
if(/timed out|timeout|abort/i.test(message))return 'timeout';
|
||
// A payload the semantic validator rejects never reached the model, so it is an ingress refusal
|
||
// rather than a model fault.
|
||
if(/consent|JPEG|PNG|WebP|empty|too large/i.test(message))return 'ingress_refused';
|
||
if(/invalid response/i.test(message))return 'provider_rejected';
|
||
return 'provider_unreachable';
|
||
}
|
||
// Both the browser's readiness probe and the operator snapshot record the same observation, so the
|
||
// cold-load figure reflects the moment the model actually became resident, not the first time an
|
||
// operator happened to look.
|
||
function observeReadiness(provider){
|
||
const readiness=visionReadiness({enabled:visionConfig.enabled,providerReady:provider.ready,modelSeen:provider.modelSeen});
|
||
modelMetrics.recordReadiness({phase:readiness.phase,ready:readiness.ready});
|
||
return readiness;
|
||
}
|
||
// A token comparison that does not leak its prefix through response timing. The digests are equal
|
||
// length by construction, so timingSafeEqual can be used even for a wrong-length guess.
|
||
function operatorTokenMatches(supplied){
|
||
if(!metricsToken||!supplied)return false;
|
||
const digest=value=>createHash('sha256').update(String(value)).digest();
|
||
return timingSafeEqual(digest(supplied),digest(metricsToken));
|
||
}
|
||
const bucketSalt=randomBytes(16).toString('hex');
|
||
function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);}
|
||
function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});}
|
||
function ingressGuard(payload){
|
||
const raw=String(payload?.imageDataUrl||'');
|
||
const match=/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/.exec(raw);
|
||
// A payload that is not shaped like a data URL is left to the semantic validator, so the user still
|
||
// gets the specific "upload a JPEG, PNG, or WebP photo" copy rather than a second opinion here.
|
||
if(!match)return null;
|
||
return inspectImageBytes(Buffer.from(match[2],'base64'),{declaredMime:match[1]});
|
||
}
|
||
function sendIngressError(res,error){
|
||
if(error instanceof IngressError)return sendJson(res,400,{error:error.message,code:error.code});
|
||
return sendJson(res,error.status||400,{error:error.message,code:error.code});
|
||
}
|
||
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=${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(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);
|
||
observeReadiness(provider);
|
||
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});
|
||
}
|
||
// Operator-only health snapshot (issue #19). With TIMMY_METRICS_TOKEN unset the route is absent,
|
||
// so a default deployment exposes no counters at all.
|
||
if(appPath==='/api/metrics'&&req.method==='GET'){
|
||
if(!metricsToken)return sendJson(res,404,{error:'Not found'});
|
||
if(!operatorTokenMatches(String(req.headers.authorization||'').replace(/^Bearer\s+/i,'')))return sendJson(res,403,{error:'Operator token required.'});
|
||
observeReadiness(await probeVisionProvider(visionConfig));
|
||
return sendJson(res,200,modelMetrics.snapshot());
|
||
}
|
||
if(appPath==='/api/analyze'&&req.method==='POST'){
|
||
// Refusals first, in cost order: the request budget, then the body, then the image bytes. None of
|
||
// these depend on the model, so hostile bytes are refused as hostile even when analysis is off —
|
||
// otherwise the disabled switch hides the ingress boundary behind a 503 and the refusal is untested.
|
||
const startedAtMs=Date.now();
|
||
const recordFailure=failureClass=>modelMetrics.recordAnalyze({outcome:'failure',failureClass,durationMs:Date.now()-startedAtMs});
|
||
const budget=analyzeLimiter.take(clientBucket(req));
|
||
if(!budget.allowed){
|
||
recordFailure('rate_limited');
|
||
res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000))));
|
||
return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'});
|
||
}
|
||
let payload;
|
||
try{payload=await readJson(req)}
|
||
catch(error){
|
||
recordFailure('ingress_refused');
|
||
const sent=sendIngressError(res,error);
|
||
// The refusal is written first, then the half-read socket is dropped so a stalled body cannot
|
||
// hold a connection or its partial buffer open.
|
||
if(error?.code==='body_timeout'||error?.code==='too_large')req.destroy?.();
|
||
return sent;
|
||
}
|
||
try{ingressGuard(payload)}
|
||
catch(error){recordFailure('ingress_refused');return sendIngressError(res,error)}
|
||
// Bytes are known to be a real photo at this point, so the disabled switch is the honest answer.
|
||
if(!visionConfig.enabled){recordFailure('disabled');return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'})}
|
||
// The slot is what the queue boundary reports, and it is released on every exit path so a failed
|
||
// analysis cannot quietly consume concurrency.
|
||
const slot=modelMetrics.enter();
|
||
try{
|
||
const result=await analyzePhoto({payload,config:visionConfig});
|
||
modelMetrics.recordAnalyze({outcome:result?.status==='suggestion'?'suggestion':'abstained',durationMs:Date.now()-startedAtMs});
|
||
return sendJson(res,200,result);
|
||
}
|
||
catch(error){
|
||
const failure=visionFailureResponse(error);
|
||
recordFailure(classifyFailure(error));
|
||
return sendJson(res,failure.status,failure.body);
|
||
}
|
||
finally{modelMetrics.leave(slot)}
|
||
}
|
||
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(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(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(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');
|
||
let body=await readFile(path);
|
||
if(appPath==='/'||appPath==='/index.html'){
|
||
const configMeta=`<meta name="timmy-base-path" content="${escapeHtmlAttribute(publicBasePath)}">\n <meta name="timmy-staging-label" content="${escapeHtmlAttribute(stagingLabel)}">`;
|
||
body=body.toString('utf8').replace(/(["'])\//g,`$1${appRoot}`).replace('<head>',`<head>\n <base href="${appRoot}">\n ${configMeta}`);
|
||
}
|
||
if(appPath==='/manifest.webmanifest'){
|
||
const manifest=JSON.parse(body.toString('utf8'));manifest.start_url=appRoot;manifest.scope=appRoot;manifest.icons=manifest.icons.map(icon=>({...icon,src:`${appRoot}${icon.src.replace(/^\//,'')}`}));body=JSON.stringify(manifest);
|
||
}
|
||
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);
|
||
}catch(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')}
|
||
}).listen(port,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));
|