SENTR retention and deletion source. No runtime data or secrets. export const DAYS=[7,30,90]; export function expiry(w){return w.expiresAt?Date.parse(w.expiresAt):Date.parse(w.createdAt)+(DAYS.includes(w.retentionDays)?w.retentionDays:30)*86400000;} export function prune(s,now=Date.now()){ for(const [id,w] of Object.entries(s.watches)){if(!Number.isFinite(Date.parse(w.createdAt)))w.createdAt=new Date(now).toISOString();if(expiry(w)<=now){delete s.watches[id];continue;}w.retentionDays=DAYS.includes(w.retentionDays)?w.retentionDays:30;w.expiresAt=new Date(expiry(w)).toISOString();w.alerts=w.alerts.filter(a=>now-Date.parse(a.time)now-Date.parse(f.time)<3600000).slice(-150); } import crypto from 'node:crypto'; import {verifyMessage} from 'ethers'; import {pimlicoRequest,pimlicoStatus} from '../lib/pimlico.mjs'; import {workerRequest,xmtpStatus} from '../lib/xmtp.mjs'; import {DAYS,prune} from '../lib/retention.mjs'; import {store as defaultStore} from '../lib/store.mjs'; import {CHAINS,ADDRESS,MAX_WATCHES} from '../lib/config.mjs'; import {Rpc} from '../lib/rpc.mjs'; import {describe} from '../lib/detect.mjs'; import {pushReady,validSubscription} from '../lib/push.mjs'; import {loadThreats,domainListed} from '../lib/threats.mjs'; const digest=s=>crypto.createHash('sha256').update(s).digest('hex'); const fail=(status,message)=>Object.assign(Error(message),{status}); const cap=req=>{const v=req.headers.authorization||'';if(!/^Bearer [a-f0-9]{64}$/.test(v))throw fail(401,'Open this watch in the browser that created it.');return digest(v.slice(7));}; export function createHandler({store=defaultStore,localPush=false,startHeads,origin=process.env.SENTR_SITE_ORIGIN||'http://127.0.0.1:8786'}={}){ let requests=0,windowStart=Date.now(),partnerCache=null,partnerUntil=0; return async function handler(req,res){ res.setHeader('Cache-Control','no-store');res.setHeader('Content-Type','application/json');res.setHeader('X-Content-Type-Options','nosniff');res.setHeader('Referrer-Policy','no-referrer'); const allowed=req.headers.origin===origin; if(allowed){res.setHeader('Access-Control-Allow-Origin',origin);res.setHeader('Vary','Origin');res.setHeader('Access-Control-Allow-Headers','Content-Type, Authorization');res.setHeader('Access-Control-Allow-Methods','GET, POST, DELETE, OPTIONS');} const answer=(code,data)=>{res.writeHead(code);res.end(JSON.stringify(data));}; try{ if(req.method==='OPTIONS'){if(!allowed)throw fail(403,'Origin not allowed');res.writeHead(204);return res.end();} if(req.headers.origin&&!allowed)throw fail(403,'Origin not allowed'); const url=new URL(req.url,'http://local'); if(req.method==='GET'&&url.pathname==='/api/partners'){if(!partnerCache||Date.now()>partnerUntil){const [pimlico,xmtp]=await Promise.all([pimlicoStatus(),xmtpStatus()]);partnerCache={pimlico,xmtp};partnerUntil=Date.now()+15000;}return answer(200,partnerCache);} if(url.pathname!=='/api/pimlico')await store.transaction(s=>prune(s)); if(req.method==='GET'&&url.pathname==='/api/status'){const {data}=await store.read();return answer(200,{mode:process.env.SENTR_STORE==='blob'?'shared-store':'local-build',pushReady:pushReady(),vapidPublicKey:process.env.SENTR_VAPID_PUBLIC||null,chains:Object.values(CHAINS).map(({id,name,explorer})=>({id,name,explorer,...data.health[id]})),sources:data.intelligence||[]});} if(req.method==='GET'&&url.pathname==='/api/feed'){const {data}=await store.read();return answer(200,{events:data.feed.filter(f=>Date.now()-Date.parse(f.time)<3600000).sort((a,b)=>Date.parse(b.time)-Date.parse(a.time)).slice(0,20).map(({type,chain,time})=>({type,chain,time})),sampled:true});} if(req.method==='POST'){ if(!String(req.headers['content-type']).startsWith('application/json'))throw fail(415,'Use JSON'); if(Date.now()-windowStart>60000){windowStart=Date.now();requests=0;}if(++requests>120)throw fail(429,'Service busy. Try again in a minute.'); let raw='';for await(const chunk of req){raw+=chunk;if(Buffer.byteLength(raw)>(url.pathname==='/api/pimlico'?96000:8192))throw fail(413,'Request too large');}let body;try{body=JSON.parse(raw);if(!body||typeof body!=='object'||Array.isArray(body))throw Error();}catch{throw fail(400,'Invalid JSON');} if(url.pathname==='/api/pimlico'){try{return answer(200,await pimlicoRequest({chain:4663,...body}));}catch(e){return answer(e.status||503,{jsonrpc:'2.0',id:body.id??1,error:{code:-32000,message:e.message}});}} if(url.pathname==='/api/xmtp'){ const id=cap(req),watch=(await store.read()).data.watches[id];if(!watch)throw fail(404,'Watch not found'); if(body.action==='disable'){await store.transaction(s=>{const w=s.watches[id];if(!w)throw fail(404,'Watch not found');delete w.xmtp;delete w.xmtpChallenge;for(const a of w.alerts)if(a.xmtp?.status==='pending')a.xmtp={status:'off'};});return answer(200,{enabled:false});} if(body.action==='challenge'){ if(!ADDRESS.test(body.address)||/^0x0{40}$/i.test(body.address))throw fail(400,'Enter a complete inbox wallet address.'); const health=await workerRequest('health');if(!health.ready)throw fail(503,'XMTP offline, sender not configured'); const result=await workerRequest('can-message',{address:body.address});if(!result.canMessage)throw fail(400,'No XMTP inbox on '+result.network+'. Create an inbox in an XMTP app first.'); const challenge={address:body.address.toLowerCase(),network:result.network,nonce:crypto.randomBytes(24).toString('hex'),expires:Date.now()+300000}; challenge.message=['SENTR XMTP alert opt-in', 'Site: '+origin,'Watch: '+id,'Inbox: '+challenge.address,'Network: '+challenge.network,'Nonce: '+challenge.nonce,'Expires: '+new Date(challenge.expires).toISOString(),'Send future alerts for this watch to this inbox. This signature moves no funds.'].join('\n'); await store.transaction(s=>{if(!s.watches[id])throw fail(404,'Watch not found');s.watches[id].xmtpChallenge=challenge;});return answer(200,{message:challenge.message,address:challenge.address,network:challenge.network}); } if(body.action==='enable'){ const c=watch.xmtpChallenge;if(!c||c.expires{const w=s.watches[id];if(!w||w.xmtpChallenge?.nonce!==c.nonce)throw fail(409,'Opt-in already used.');w.xmtp={address:c.address,network:c.network,since:new Date().toISOString()};delete w.xmtpChallenge;});return answer(200,{enabled:true,address:c.address,network:c.network}); } throw fail(400,'Unsupported XMTP action.'); } if(url.pathname==='/api/watch'){ if(!ADDRESS.test(body.address)||/^0x0{40}$/i.test(body.address))throw fail(400,'Paste a complete wallet address: 0x followed by 40 hex characters.'); if(!Array.isArray(body.chains))throw fail(400,'Choose at least one supported chain.'); const chains=[...new Set(body.chains)];if(!chains.length||chains.some(id=>!Number.isInteger(id)||!CHAINS[id]))throw fail(400,'Choose at least one supported chain.'); if(body.retentionDays!=null&&!DAYS.includes(body.retentionDays))throw fail(400,'Choose 7, 30 or 90 days.'); const retentionDays=body.retentionDays||30; const heads=startHeads?await startHeads(chains):Object.fromEntries(await Promise.all(chains.map(async id=>{const rpc=new Rpc(CHAINS[id].rpc,{budget:Date.now()+8000});if(Number(BigInt(await rpc.call('eth_chainId')))!==id)throw Error('Chain mismatch');return [id,Number(BigInt(await rpc.call('eth_blockNumber')))];}))); const token=crypto.randomBytes(32).toString('hex'),id=digest(token); const watch={id,address:body.address.toLowerCase(),chains,subscription:null,cursors:{...heads},started:{...heads},hashes:{},recipients:{},seenSpenders:{},alerts:[],createdAt:new Date().toISOString(),retentionDays,expiresAt:new Date(Date.now()+retentionDays*86400000).toISOString(),lastScan:null}; await store.transaction(s=>{if(Object.keys(s.watches).length>=MAX_WATCHES)throw fail(503,'Watch capacity reached. Try again later.');s.watches[id]=watch;});return answer(201,{token,address:watch.address,chains,retentionDays,expiresAt:watch.expiresAt,status:'saved'}); } if(url.pathname==='/api/retention'){ const id=cap(req);if(!DAYS.includes(body.retentionDays))throw fail(400,'Choose 7, 30 or 90 days.'); const result=await store.transaction(s=>{const w=s.watches[id];if(!w)throw fail(404,'Watch not found');w.retentionDays=body.retentionDays;w.expiresAt=new Date(Date.now()+body.retentionDays*86400000).toISOString();return {retentionDays:w.retentionDays,expiresAt:w.expiresAt};});return answer(200,result); } if(url.pathname==='/api/subscription'){ const id=cap(req);if(!pushReady())throw fail(503,'Push is not configured yet. Your inbox still works.');if(!validSubscription(body.subscription,{local:localPush}))throw fail(400,'Push subscription is invalid or unsupported.'); await store.transaction(s=>{const w=s.watches[id];if(!w)throw fail(404,'Watch not found');w.subscription={endpoint:body.subscription.endpoint,keys:{p256dh:body.subscription.keys.p256dh,auth:body.subscription.keys.auth}};});return answer(200,{status:'enabled'}); } if(url.pathname==='/api/domain'){ let host;try{const u=new URL(body.url);if(u.protocol!=='https:'&&u.protocol!=='http:')throw Error();host=u.hostname;}catch{throw fail(400,'Enter a complete website URL.');} const threats=await loadThreats({refresh:false});return answer(200,{listed:domainListed(host,threats.domainSet),available:threats.domains.length>0,source:threats.sources.find(s=>s.kind==='domains')||null}); } } if(url.pathname==='/api/watch'&&req.method==='GET'){ const id=cap(req),{data}=await store.read(),w=data.watches[id];if(!w)throw fail(404,'Watch not found');return answer(200,{address:w.address,chains:w.chains,cursors:w.cursors,started:w.started,lastScan:w.lastScan,createdAt:w.createdAt,retentionDays:w.retentionDays,expiresAt:w.expiresAt,recipients:w.recipients,pushEnabled:!!w.subscription,xmtp:w.xmtp||null,alerts:w.alerts.slice().reverse().map(a=>({...a,...describe(a),explorer:CHAINS[a.chain].explorer+'/tx/'+a.tx})),health:data.health}); } if(url.pathname==='/api/watch'&&req.method==='DELETE'){const id=cap(req);await store.transaction(s=>{delete s.watches[id];});return answer(200,{deleted:true});} throw fail(404,'Not found'); }catch(e){answer(e.status||503,{error:e.status?e.message:'Service temporarily unavailable. Your watch was not changed.'});} }; } export default createHandler();