import {z} from 'zod'; import {PolicySchema} from '../../packages/core/policy.mjs'; import {globalBookmarksValid} from './bookmarks.mjs'; const version=z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER); function snapshot(store,id){ const row=store.db.prepare('SELECT version,time,actor,policy FROM policies WHERE version=?').get(id); if(!row)return null; const policy=PolicySchema.parse(JSON.parse(row.policy)); // Global history never carries device personal collections, even from old stores. policy.browser.bookmarks.personal={revision:0,items:[]}; const groupsRow=store.db.prepare('SELECT groups FROM policy_group_history WHERE version=?').get(id); const profilesRow=store.db.prepare('SELECT configuration FROM user_profile_history WHERE version=?').get(id); return {...row,policy,userProfiles:profilesRow?JSON.parse(profilesRow.configuration):null,groups:groupsRow?JSON.parse(groupsRow.groups):[],groupHistoryAvailable:!!groupsRow}; } export function policyDiff(before,after,max=500){ const changes=[];let truncated=false; function walk(a,b,path){ if(JSON.stringify(a)===JSON.stringify(b))return; if(a&&b&&typeof a==='object'&&typeof b==='object'&&!Array.isArray(a)&&!Array.isArray(b)){ for(const key of [...new Set([...Object.keys(a),...Object.keys(b)])].sort())walk(a[key],b[key],`${path}/${key.replaceAll('~','~0').replaceAll('/','~1')}`); return; } if(changes.length>=max){truncated=true;return;} changes.push({path,kind:a===undefined?'added':b===undefined?'removed':'changed',...(a===undefined?{}:{before:a}),...(b===undefined?{}:{after:b})}); } walk(before,after,'');return {changes,truncated}; } export function installPolicyHistory(app,store,publisher){ app.get('/api/admin/policy/history',(req,res)=>{ const query=z.object({before:version.optional(),limit:z.coerce.number().int().min(1).max(100).default(20)}).strict().parse(req.query); const currentVersion=store.getRaw('policy').version; const rows=store.db.prepare('SELECT version,time,actor FROM policies WHERE versionquery.limit,versions=rows.slice(0,query.limit).map(row=>({...row,current:row.version===currentVersion})); res.json({currentVersion,versions,nextBefore:more?versions.at(-1).version:null}); }); app.get('/api/admin/policy/history/:version',(req,res)=>{ const item=snapshot(store,version.parse(req.params.version)); if(!item)return res.status(404).json({error:'Policy version not found'}); res.json(item); }); app.get('/api/admin/policy/diff',(req,res)=>{ const query=z.object({from:version,to:version}).strict().parse(req.query); const from=snapshot(store,query.from),to=snapshot(store,query.to); if(!from||!to)return res.status(404).json({error:'Policy version not found'}); res.json({...query,...policyDiff(from.policy,to.policy),groupDiff:policyDiff(from.groups,to.groups),profileDiff:policyDiff(from.userProfiles,to.userProfiles)}); }); app.post('/api/admin/policy/restore',(req,res)=>{ const body=z.object({sourceVersion:version,expectedVersion:version,approve:z.literal(true)}).strict().parse(req.body); const current=store.getRaw('policy'); if(current.version!==body.expectedVersion)return res.status(409).json({error:'Policy version conflict'}); const source=snapshot(store,body.sourceVersion); if(!source)return res.status(404).json({error:'Policy version not found'}); const policy=PolicySchema.parse({...source.policy,version:current.version+1,expiresAt:new Date(Date.now()+7*86400000).toISOString()}); if(!globalBookmarksValid(store,policy))return res.status(409).json({error:'Historical company bookmark IDs conflict with current personal bookmarks'}); if(!source.groupHistoryAvailable&&(store.getRaw('policyGroups')||[]).length)return res.status(409).json({error:'Historical group assignment snapshot unavailable; review group changes explicitly'}); if(!source.userProfiles&&(store.getRaw('userProfiles')?.profiles?.length||store.getRaw('userProfiles')?.categories?.length))return res.status(409).json({error:'Historical user profile snapshot unavailable'}); publisher.submit(req,res,policy,'policy.restored',{type:'group-restore',sourceVersion:body.sourceVersion,groups:source.groups,configuration:source.userProfiles||{profiles:[],categories:[]}},{sourceVersion:body.sourceVersion,expiresAt:policy.expiresAt}); }); }