Files
safetynet/apps/server/policy-history.mjs
T

60 lines
4.4 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
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 version<? ORDER BY version DESC LIMIT ?').all(query.before??Number.MAX_SAFE_INTEGER,query.limit+1);
const more=rows.length>query.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});
});
}