Files
safetynet/apps/server/app.mjs
T

286 lines
26 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
import express from 'express';
import {installRequestTelemetry,traceOperation} from './telemetry.mjs';
2026-10-13 10:33:31 +02:00
import transferFacts from '../../packages/core/transfer-facts.cjs';
import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs';
import agentAudit from '../../packages/core/agent-audit.cjs';
import interactionAudit from '../../packages/core/interaction-facts.cjs';
2026-10-13 10:33:31 +02:00
import {installEvidence} from './evidence.mjs';
import {installIncidents} from './incidents.mjs';
import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs';
import {installLibraryRoutes} from './library.mjs';
import {runApprovedAI} from './ai-gateway.mjs';
2026-10-13 10:33:31 +02:00
import { z } from 'zod';
import { randomUUID,timingSafeEqual } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { PolicySchema, inspect, inspectForAI, allowsConfidentialLocal } from '../../packages/core/policy.mjs';
import { digest, token } from './store.mjs';
import {policyPublisher} from './policy-review.mjs';
import {installPolicyHistory} from './policy-history.mjs';
import {installUserProfiles} from './user-profiles.mjs';
import {installPolicyGroups} from './policy-groups.mjs';
import { adminMiddleware, authRoutes,cookie } from './auth.mjs';
import {bootstrapAllowed,normalizePrincipal} from './admin-access.mjs';
import { tracer } from './telemetry.mjs';
import { toECS,toCEF } from '../../packages/core/siem.mjs';
import {installSIEMRoutes} from './siem.mjs';
import {createCompanyNewsReader} from './company-news.mjs';
2026-10-13 10:33:31 +02:00
import { inspector } from './inspection.mjs';
import { SecuritySchema, VisualBrandSchema, VisualOriginSchema } from '../../packages/core/security.mjs';
import { defaultSemantic } from '../../packages/core/semantic.mjs';
import { installPolicyChannel } from './policy-channel.mjs';
import enterpriseInspection from '../browser/enterprise-inspection.cjs';
import icapClient from '../browser/icap-client.cjs';
import {EnterpriseSchema} from '../../packages/core/enterprise.mjs';
import { policyPresets, previewPreset } from '../../packages/core/presets.mjs';
import { enterpriseReadiness } from '../../packages/core/enterprise.mjs';
export function createApp(store,config) {
const app=express();app.disable('x-powered-by');installRequestTelemetry(app);
const readCompanyNews=createCompanyNewsReader({allowedHosts:config.companyNewsAllowedHosts||[]});
2026-10-13 10:33:31 +02:00
const limits=new Map();
app.use((req,res,next)=>{
res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer','Cache-Control':'no-store',
'Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"});
2026-10-13 10:33:31 +02:00
if(!['GET','HEAD','OPTIONS'].includes(req.method)) {
if(req.headers.origin && req.headers.origin!==config.publicUrl) return res.status(403).json({error:'Origin rejected'});
if(req.headers['sec-fetch-site']==='cross-site') return res.status(403).json({error:'Cross-site request rejected'});
}
const now=Date.now(),auth=(req.headers.authorization||'').replace(/^Bearer /,'');
// Corporate NATs share an IP. Only an authenticated, active device gets its
// own bucket; arbitrary bearer strings cannot evade the IP limit.
const enrolled=auth.length>=32&&auth.length<=200?store.db.prepare('SELECT id FROM devices WHERE token_hash=? AND revoked=0').get(digest(auth)):null;
const bootstrap=bootstrapAllowed(config)&&auth&&config.adminToken&&timingSafeEqual(Buffer.from(digest(auth)),Buffer.from(digest(config.adminToken)));
const sessionCredential=cookie(req,'sn_session');
const sessionIdentity=!enrolled&&!bootstrap&&sessionCredential.length>=32&&sessionCredential.length<=200?store.db.prepare('SELECT hash FROM sessions WHERE hash=? AND expires>?').get(digest(sessionCredential),now):null;
const key=enrolled?`device:${enrolled.id}`:bootstrap?'admin:bootstrap':sessionIdentity?`admin:${sessionIdentity.hash}`:`ip:${req.socket.remoteAddress||'unknown'}`;
let count=limits.get(key);if(!count || count.until<now) {count={until:now+60000,n:0};limits.set(key,count);}
if(++count.n>300) return res.status(429).json({error:'Rate limit exceeded'});
if(limits.size>10000)for(const [k,v] of limits)if(v.until<now)limits.delete(k);
next();
});
app.use(express.json({limit:'2mb'}));
app.get('/health',(_req,res)=>res.json({status:'ok',version:'0.1.0'}));
app.get('/api/auth/status',(_req,res)=>res.json({sso:!!config.oidcIssuer,publicUrl:config.publicUrl,bootstrapAllowed:bootstrapAllowed(config)}));
authRoutes(app,store,config);
const admin=adminMiddleware(store,config);
const device=(req,res,next)=>{
const auth=(req.headers.authorization||'').replace(/^Bearer /,'');
const d=store.db.prepare('SELECT * FROM devices WHERE token_hash=? AND revoked=0').get(digest(auth));
if(!d)return res.status(401).json({error:'Device is unenrolled or revoked'});
req.device=d;next();
};
installBookmarkRoutes(app,store,device,admin);
installLibraryRoutes(app,store,config,device);
2026-10-13 10:33:31 +02:00
installEvidence(app,store,config,device,admin);
const managementChannel=installPolicyChannel(app,store,device);
app.get('/api/admin/management-channel',admin,(_req,res)=>res.json({version:managementChannel.version,signatureKey:managementChannel.signatureKey,requirePQManagement:!!config.requirePQManagement}));
function enroll(user,body,protectedBootstrap=false,verified=false) {
const b=z.object({name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(body);
const id=randomUUID(),credential=token();
store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run(id,b.name,b.platform,user,digest(credential),new Date().toISOString());
if(verified)store.db.prepare('UPDATE devices SET verified_subject=? WHERE id=?').run(user,id);
store.event(id,'device.enrolled',{platform:b.platform,actor:user});
return {id,token:credential,publicKey:store.publicKey(),...(!config.requirePQManagement&&!protectedBootstrap?{policy:store.envelope(id)}:{}),managementChannel};
}
app.post('/api/device/enroll',(req,res)=>{
const code=z.string().min(20).max(200).parse(req.body.code);
const e=store.db.prepare('SELECT * FROM enrollments WHERE hash=? AND expires>?').get(digest(code),Date.now());
if(!e)return res.status(401).json({error:'Enrollment code invalid or expired'});
// Validate before consuming the one-use code.
z.object({name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(req.body);
store.db.prepare('DELETE FROM enrollments WHERE hash=?').run(e.hash);res.json(enroll(e.user,req.body,req.headers['x-safetynet-pq-bootstrap']==='1'));
});
app.post('/api/device/sso/start',(req,res)=>{
if(!config.oidcIssuer)return res.status(409).json({error:'SSO not configured'});
const id=token(),secret=token();store.db.prepare('INSERT INTO tickets VALUES(?,?,?,?)').run(id,digest(secret),Date.now()+300000,null);
res.json({id,secret,url:`${config.publicUrl}/auth/login?ticket=${id}`});
});
app.post('/api/device/sso/poll',(req,res)=>{
const b=z.object({id:z.string().max(100),secret:z.string().max(100),name:z.string().min(1).max(100),platform:z.enum(['win32','darwin','linux'])}).parse(req.body);
const t=store.db.prepare('SELECT * FROM tickets WHERE id=? AND secret_hash=? AND expires>?').get(b.id,digest(b.secret),Date.now());
if(!t)return res.status(401).json({error:'Device login expired'});
if(!t.credential)return res.status(202).json({pending:true});
store.db.prepare('DELETE FROM tickets WHERE id=?').run(b.id);res.json(enroll(JSON.parse(t.credential).sub,b,req.headers['x-safetynet-pq-bootstrap']==='1',true));
});
app.get('/api/device/policy',device,(req,res)=>config.requirePQManagement?res.status(403).json({error:'Protected management channel required'}):res.json(store.envelope(req.device.id)));
app.post('/api/device/heartbeat',device,(req,res)=>{
const b=z.object({policyVersion:z.number().int().nonnegative()}).parse(req.body);
store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true});
});
app.post('/api/device/events',device,(req,res)=>{
const b=z.object({eventId:z.string().uuid(),kind:z.enum(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body);
if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching clipboard lifecycle event'});
if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'});
2026-10-13 10:33:31 +02:00
if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'});
if(b.request&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'Request facts require an upload event'});
// Older clients reported body hashes as file facts. Never persist those hashes.
if(b.kind.startsWith('upload.')&&b.file){b.request=requestTransferFacts.requestTransferFacts({bytes:b.file.bytes,detectedType:b.file.detectedType});delete b.file;}
2026-10-13 10:33:31 +02:00
if(b.kind.startsWith('vault.')&&(b.host!==undefined||b.ruleId!==undefined))return res.status(400).json({error:'Vault audit events accept lifecycle metadata only'});
store.db.exec('BEGIN IMMEDIATE');try{
const inserted=store.db.prepare('INSERT OR IGNORE INTO device_events VALUES(?,?)').run(req.device.id,b.eventId);
if(inserted.changes)store.event(req.device.id,b.kind,b);store.db.exec('COMMIT');res.json({ok:true});
}catch(e){store.db.exec('ROLLBACK');throw e;}
});
app.get('/api/device/start-page/news',device,async(req,res)=>{try{const p=store.effectivePolicy(req.device.id);if(Date.parse(p.expiresAt)<=Date.now())return res.status(403).json({error:'Policy expired'});const news=await readCompanyNews(p);res.json({policyVersion:p.version,news});}catch{res.status(502).json({error:'Company news unavailable'});}});
2026-10-13 10:33:31 +02:00
app.post('/api/device/ai',device,async(req,res,next)=>{
try{const b=z.object({prompt:z.string().min(1).max(100000)}).strict().parse(req.body),p=store.effectivePolicy(req.device.id);
const answer=await runApprovedAI({store,config,deviceId:req.device.id,policy:p,prompt:b.prompt});res.json({answer});
}catch(e){if([403,409,502].includes(e.status))return res.status(e.status).json({error:e.message,...(e.ruleId?{ruleId:e.ruleId}:{})});next(e);}
2026-10-13 10:33:31 +02:00
});
app.use('/api/admin',admin);
installSIEMRoutes(app,store,config);
2026-10-13 10:33:31 +02:00
installIncidents(app,store);
app.get('/api/admin/session',(req,res)=>res.json({principal:req.principal}));
app.get('/api/admin/sessions',(req,res)=>{
const rows=store.db.prepare('SELECT hash,expires,principal FROM sessions WHERE expires>? ORDER BY expires DESC LIMIT 501').all(Date.now());
const sessions=[];store.db.exec('BEGIN IMMEDIATE');
try{
for(const row of rows.slice(0,500)){
const principal=normalizePrincipal(JSON.parse(row.principal));if(!principal)continue;
let handle=store.db.prepare('SELECT handle FROM management_session_handles WHERE session_hash=?').get(row.hash)?.handle;
if(!handle){handle=randomUUID();store.db.prepare('INSERT INTO management_session_handles VALUES(?,?)').run(handle,row.hash);}
sessions.push({id:handle,subject:principal.sub,name:principal.name||principal.sub,role:principal.role,expiresAt:new Date(row.expires).toISOString()});
}
store.event(null,'management.sessions-reviewed',{actor:req.principal.sub,count:sessions.length});store.db.exec('COMMIT');
}catch(error){store.db.exec('ROLLBACK');throw error;}
res.json({sessions,truncated:rows.length>500});
});
app.post('/api/admin/sessions/:id/revoke',(req,res)=>{
const id=z.string().uuid().parse(req.params.id);z.object({}).strict().parse(req.body);
const target=store.db.prepare('SELECT session_hash FROM management_session_handles WHERE handle=?').get(id);
if(!target)return res.status(404).json({error:'Management session not found'});
store.db.exec('BEGIN IMMEDIATE');
try{const result=store.db.prepare('DELETE FROM sessions WHERE hash=?').run(target.session_hash);if(!result.changes)throw Error('Session revocation conflict');store.event(null,'management.session-revoked',{actor:req.principal.sub,count:1});store.db.exec('COMMIT');}
catch(error){store.db.exec('ROLLBACK');throw error;}
res.json({ok:true,revoked:1});
});
app.post('/api/admin/integrations/icap/probe',async(req,res,next)=>{
try{const settings=EnterpriseSchema.parse({icap:req.body}).icap;const result=await icapClient.probeIcap(settings,enterpriseInspection.deploymentTLS());store.event(null,'integration.tested',{actor:req.principal.sub,integration:'icap',ready:result.ready});res.json({...result,scope:'management-server',check:'options-only',contentSent:false});}
catch{res.status(400).json({error:'ICAP configuration or deployment certificates are unavailable'});}
});
app.get('/api/admin/presets',(_req,res)=>res.json({presets:policyPresets}));
app.get('/api/admin/integrations',(_req,res)=>res.json({readiness:enterpriseReadiness(store.get('policy'))}));
app.post('/api/admin/presets/preview',(req,res)=>{const b=z.object({id:z.string().max(80),policy:PolicySchema}).strict().parse(req.body);if(!policyPresets.some(p=>p.id===b.id))return res.status(400).json({error:'Unknown enterprise preset'});res.json(previewPreset(b.policy,b.id));});
const publisher=policyPublisher(app,store,config);
installPolicyGroups(app,store,publisher);
installUserProfiles(app,store,publisher);
installPolicyHistory(app,store,publisher);
app.put('/api/admin/security/blocklists',(req,res)=>{
const b=z.object({expectedVersion:z.number().int().positive(),blockedHosts:z.array(z.string()).max(10000),blockedIPs:z.array(z.string()).max(10000)}).strict().parse(req.body);
SecuritySchema.parse({dns:{blockedHosts:b.blockedHosts,blockedIPs:b.blockedIPs}});
const p=PolicySchema.parse(store.get('policy'));if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
p.security.dns.blockedHosts=b.blockedHosts;p.security.dns.blockedIPs=b.blockedIPs;p.version++;
publisher.submit(req,res,p,'security.blocklists-published');
});
app.post('/api/admin/security/visual-reference',async(req,res,next)=>{
try{
const b=z.object({expectedVersion:z.number().int().positive(),brandId:z.string().min(1).max(80),label:z.string().min(1).max(100),allowedOrigins:z.array(VisualOriginSchema).min(1).max(50),data:z.string().max(1800000).regex(/^[A-Za-z0-9+/]*={0,2}$/),scope:z.enum(['viewport','login-form'])}).strict().parse(req.body);
if(store.get('policy').version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
const bytes=Buffer.from(b.data,'base64');if(!bytes.length||bytes.length>1024*1024||bytes.toString('base64')!==b.data)return res.status(400).json({error:'Reference must be canonical base64 PNG up to 1 MB'});
const {fingerprintPNG}=await import('../../packages/core/visual-image.cjs');const result=fingerprintPNG(bytes);
if(result.usable!==true)return res.status(400).json({error:'Reference has insufficient visual detail; use a trusted nonblank login screenshot'});
const reference={hash:result.hash,algorithm:'phash-dct32-v1',scope:b.scope,viewport:{width:result.width,height:result.height}};
const brand=VisualBrandSchema.parse({id:b.brandId,label:b.label,allowedOrigins:b.allowedOrigins,references:[reference]});
store.event(null,'phishing.reference-derived',{actor:req.principal.sub,ruleId:b.brandId,version:b.expectedVersion});
const quality=z.object({contrast:z.number().finite(),stddev:z.number().finite(),dynamicRange:z.number().finite(),entropy:z.number().finite(),detail:z.number().finite()}).parse(result.quality);
res.json({brand,quality:{...quality,width:result.width,height:result.height},warning:'Draft only. Explicitly publish signed policy to enable this reference. Perceptual similarity is not proof of a phishing page; use trusted reference images and validate false positives.'});
}catch(e){next(e);}
});
app.get('/api/admin/teaching',(_req,res)=>res.json({drafts:store.db.prepare('SELECT * FROM teaching ORDER BY time DESC LIMIT 100').all().map(r=>({...r,reference:JSON.parse(r.reference)}))}));
app.post('/api/admin/teaching',async(req,res,next)=>{
try{const b=z.object({label:z.string().min(1).max(100),data:z.string().max(1400000).regex(/^[A-Za-z0-9+/]*={0,2}$/),filename:z.string().max(200)}).strict().parse(req.body);const bytes=Buffer.from(b.data,'base64');if(!bytes.length||bytes.length>1024*1024)return res.status(413).json({error:'Reference document limit is 1 MB'});
const reference=await inspector(config,'/reference-document',{data:b.data,filename:b.filename,policy:store.get('policy')});
2026-10-13 10:33:31 +02:00
const id=randomUUID();store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run(id,new Date().toISOString(),req.principal.sub,b.label,'draft',JSON.stringify(reference),null,null);store.event(null,'teaching.drafted',{actor:req.principal.sub,referenceId:id});res.json({id,reference,status:'draft'});
}catch(e){next(e);}
});
app.post('/api/admin/teaching/:id/publish',(req,res)=>{
const b=z.object({expectedVersion:z.number().int().positive(),approve:z.literal(true),mode:z.enum(['complete','available-fingerprints']).default('complete')}).strict().parse(req.body),draft=store.db.prepare("SELECT * FROM teaching WHERE id=? AND status='draft'").get(req.params.id);
if(!draft)return res.status(404).json({error:'Draft not found'});const p=PolicySchema.parse(store.get('policy'));if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
const reference=JSON.parse(draft.reference);if(!reference.registrationReady&&b.mode==='complete')return res.status(409).json({error:'Complete local inspection required before publication; available-fingerprints mode can explicitly approve partial coverage'});p.dlp.rules.push({id:draft.id,label:draft.label,kind:'sha256',value:reference.sha256,action:'block'});
for(const [index,value] of reference.fragmentHashes.slice(0,1000).entries())p.dlp.rules.push({id:`${draft.id}:${index}`,label:draft.label,kind:'fragment-sha256',value,action:'block'});p.version++;PolicySchema.parse(p);
const coverage=reference.registrationReady?'complete':'partial';publisher.submit(req,res,p,'teaching.published',{type:'teaching',id:draft.id,coverage,mode:b.mode},{coverage});
});
app.get('/api/admin/overview',(req,res)=>{
let policy,migrationRequired=false;try{policy=store.get('policy');}catch(error){if(!(error instanceof z.ZodError))throw error;const raw=store.getRaw('policy');policy=PolicySchema.parse({...raw,dlp:{...raw.dlp,semantic:defaultSemantic()}});migrationRequired=true;}
const devices=store.db.prepare('SELECT id,name,platform,user,revoked,last_seen,policy_version,document_analysis_enabled FROM devices ORDER BY last_seen DESC').all().map(d=>({...d,documentAnalysisEnabled:d.document_analysis_enabled===null?policy.inspection.documentAnalysisEnabled:!!d.document_analysis_enabled}));
const alerts=store.db.prepare('SELECT * FROM alerts ORDER BY id DESC LIMIT 100').all();
const events=store.db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 100').all().map(e=>({...e,metadata:JSON.parse(e.metadata)}));
res.json({policy,migrationRequired,publicKey:store.publicKey(),devices,events,alerts,principal:req.principal,siem:config.siemStatus?.()||null,auditRetention:config.auditRetentionStatus?.()||{enabled:false},governance:{requireIndependentPolicyApproval:!!config.requireIndependentPolicyApproval}});
});
app.get('/api/admin/audit/integrity',async(_req,res)=>res.json(await traceOperation('audit.verify',{},()=>store.auditIntegrity.verify())));
app.get('/api/admin/audit/entries',(req,res)=>{
const q=z.object({after:z.coerce.number().int().nonnegative().default(0),limit:z.coerce.number().int().min(1).max(1000).default(100)}).strict().parse(req.query);
res.json(store.auditIntegrity.entries(q));
});
2026-10-13 10:33:31 +02:00
app.get('/api/admin/events/export',(req,res)=>{
const query=z.object({after:z.coerce.number().int().nonnegative().default(0),limit:z.coerce.number().int().min(1).max(1000).default(100),format:z.enum(['ecs','cef']).default('ecs')}).parse(req.query);
const rows=store.db.prepare('SELECT * FROM events WHERE id>? ORDER BY id LIMIT ?').all(query.after,query.limit).map(e=>({...e,metadata:JSON.parse(e.metadata),audit:store.auditIntegrity.linkage(e.id)}));
2026-10-13 10:33:31 +02:00
res.setHeader('X-SafetyNet-Next-Cursor',String(rows.at(-1)?.id??query.after));
res.type(query.format==='ecs'?'application/x-ndjson':'text/plain').send(rows.map(e=>query.format==='ecs'?JSON.stringify(toECS(e,store.get('installationId'))):toCEF(e,store.get('installationId'))).join('\n')+(rows.length?'\n':''));
});
app.put('/api/admin/policy',(req,res)=>{
const p=PolicySchema.parse(req.body),previous=store.getRaw('policy');
if(!globalBookmarksValid(store,p))return res.status(400).json({error:'Global policies require empty personal bookmarks and distinct company IDs'});
if(p.version!==previous.version+1)return res.status(409).json({error:'Refresh the console: version must increase by exactly one'});
const expires=Date.parse(p.expiresAt);if(expires<=Date.now() || expires>Date.now()+30*86400000)return res.status(400).json({error:'Policy expiry must be within the next 30 days'});
publisher.submit(req,res,p);
});
app.post('/api/admin/enrollments',(req,res)=>{
const user=z.string().min(1).max(200).parse(req.body.user),code=token();
store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,user);
store.event(null,'enrollment.created',{actor:req.principal.sub,user});res.json({code,expiresInSeconds:600});
});
app.post('/api/admin/devices/:id/revoke',(req,res)=>{
const r=store.db.prepare('UPDATE devices SET revoked=1 WHERE id=? AND revoked=0').run(req.params.id);
if(!r.changes)return res.status(404).json({error:'Active device not found'});
store.event(req.params.id,'device.revoked',{actor:req.principal.sub});res.json({ok:true});
});
app.patch('/api/admin/devices/:id/inspection',(req,res)=>{
const b=z.object({expectedVersion:z.number().int().positive(),documentAnalysisEnabled:z.boolean()}).strict().parse(req.body),p=store.get('policy');
if(p.version!==b.expectedVersion)return res.status(409).json({error:'Policy version conflict'});
if(!store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(req.params.id))return res.status(404).json({error:'Active device not found'});
p.version++;publisher.submit(req,res,p,'inspection.policy-published',{type:'inspection',deviceId:req.params.id,enabled:b.documentAnalysisEnabled},{documentAnalysisEnabled:b.documentAnalysisEnabled});
});
app.post('/api/admin/policy/test',async(req,res,next)=>{
try{const b=z.object({text:z.string().max(100000),policy:PolicySchema}).parse(req.body);let result=inspect(Buffer.from(b.text),b.policy);
if(result.decision==='allow'&&b.policy.dlp.semantic.enabled)result=await inspector(config,'/inspect',{data:Buffer.from(b.text).toString('base64'),policy:b.policy});res.json(result);
}catch(e){next(e);}
});
app.post('/api/admin/semantic/reference',async(req,res,next)=>{
try{
const b=z.object({description:z.string().min(10).max(4000)}).strict().parse(req.body);
const result=await inspector(config,'/embed',{data:Buffer.from(b.description).toString('base64'),policy:store.get('policy')});
2026-10-13 10:33:31 +02:00
if(!result.coverage.complete||!result.coverage.extractionComplete)return res.status(409).json({error:'Complete local text inspection is required to create a semantic reference'});
store.event(null,'semantic.reference-created',{actor:req.principal.sub,model:result.model,revision:result.revision,dimensions:result.dimensions});res.json(result);
}catch(e){next(e);}
});
app.post('/api/admin/semantic/document-reference',async(req,res,next)=>{
try{
const b=z.object({data:z.string().max(1400000).regex(/^[A-Za-z0-9+/]*={0,2}$/),filename:z.string().min(1).max(200)}).strict().parse(req.body),bytes=Buffer.from(b.data,'base64');
if(!bytes.length||bytes.length>1048576||bytes.toString('base64')!==b.data)return res.status(413).json({error:'Semantic reference document must be canonical base64 up to 1 MiB'});
const result=await inspector(config,'/embed',{data:b.data,policy:store.get('policy')});
2026-10-13 10:33:31 +02:00
if(!result.coverage.complete||!result.coverage.extractionComplete)return res.status(409).json({error:'Complete extraction and chunk coverage are required for a semantic document reference'});
store.event(null,'semantic.document-reference-created',{actor:req.principal.sub,model:result.model,revision:result.revision,dimensions:result.dimensions});res.json(result);
}catch(e){next(e);}
});
app.use(express.static(fileURLToPath(new URL('./public',import.meta.url)),{index:'index.html'}));
app.use((error,_req,res,_next)=>{
config.onError?.(error);
const invalid=error instanceof z.ZodError || error.type==='entity.parse.failed';
res.status(invalid?400:error.type==='entity.too.large'?413:[400,403,404,409].includes(error.status)?error.status:500).json({error:invalid?'Request validation failed':'Request failed',...(error instanceof z.ZodError?{issues:error.issues.map(v=>({path:v.path.join('.'),message:v.message}))}:{})});
});
return app;
}
async function readLimited(response,maxBytes) {
const chunks=[];let size=0;
for await(const chunk of response.body){size+=chunk.length;if(size>maxBytes){await response.body.cancel().catch(()=>{});throw Error('AI response too large');}chunks.push(chunk);}
return Buffer.concat(chunks).toString('utf8');
}