2026-10-13 10:33:31 +02:00
import { z } from 'zod' ;
import { SETTINGS_SECTIONS } from './managed-settings.mjs' ;
import { BookmarkItemsSchema } from './bookmarks.mjs' ;
const id = z . string (). min ( 1 ). max ( 80 ). regex ( /^[A-Za-z0-9][A-Za-z0-9._-]*$/ );
const unique = ( schema , max )=> z . array ( schema ). max ( max ). refine ( items => new Set ( items ). size === items . length , 'Values must be unique' );
const host = z . string (). max ( 253 ). regex ( /^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i );
export const GroupRestrictionsSchema = z . object ({
blockedHosts : unique ( host , 200 ). default ([]), blockedPhrases : unique ( z . string (). min ( 1 ). max ( 4096 ), 500 ). default ([]),
disableDownloads : z . boolean (). default ( false ), disableAI : z . boolean (). default ( false ), disableAgents : z . boolean (). default ( false ),
hideSettings : z . boolean (). default ( false ), requireDocumentScan : z . boolean (). default ( false ),
hiddenSections : unique ( z . enum ( SETTINGS_SECTIONS ), SETTINGS_SECTIONS . length ). default ([]),
lockedSections : unique ( z . enum ( SETTINGS_SECTIONS ), SETTINGS_SECTIONS . length ). default ([]),
managedBookmarks : BookmarkItemsSchema . default ([])
}). strict ();
2026-10-13 23:19:22 +02:00
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
2026-10-13 10:33:31 +02:00
export const PolicyGroupSchema = z . object ({ id , name : z . string (). min ( 1 ). max ( 100 ). refine ( v => ! /[\u0000-\u001f\u007f]/ . test ( v )),
memberDeviceIds : unique ( id , 10000 ), restrictions : GroupRestrictionsSchema ,
rollout : z . object ({ stage : z . enum ([ 'pilot' , 'expanded' , 'all' ]), deviceIds : unique ( id , 10000 ). default ([])}). strict ()
}). strict (). superRefine (( group , ctx )=>{
if ( group . rollout . stage === 'all' && group . rollout . deviceIds . length ) ctx . addIssue ({ code : 'custom' , message : 'All-members rollout requires empty cohort' });
if ( group . rollout . stage !== 'all' && ( ! group . rollout . deviceIds . length || group . rollout . deviceIds . some ( i => ! group . memberDeviceIds . includes ( i )))) ctx . addIssue ({ code : 'custom' , message : 'Pilot and expanded cohorts must be nonempty subsets of members' });
if ( Buffer . byteLength ( JSON . stringify ( group )) > 250000 ) ctx . addIssue ({ code : 'custom' , message : 'Group exceeds the 250 KB feature budget' });
});
export const GroupAssignmentSchema = z . object ({ deviceId : id , groups : unique ( id , 100 ), profiles : unique ( id , 100 ). optional ()}). strict ();
export function groupApplies ( group , deviceId ){ return group . memberDeviceIds . includes ( deviceId ) && ( group . rollout . stage === 'all' || group . rollout . deviceIds . includes ( deviceId ));}
// The company baseline always survives. Boolean restrictions combine with OR;
// block lists and hidden sections combine with a stable union, never replacement.
export function applyPolicyGroups ( policy , groups , deviceId ){
const result = structuredClone ( policy ), applied = groups . map ( g => PolicyGroupSchema . parse ( g )). filter ( g => groupApplies ( g , deviceId )). sort (( a , b )=> a . id . localeCompare ( b . id , 'en' ));
const union = ( a , b )=>[... new Set ([... a ,... b ])]. sort ();
for ( const group of applied ){ const r = group . restrictions ;
result . browser . blockedHosts = union ( result . browser . blockedHosts , r . blockedHosts );
result . security . content . blockedPhrases = union ( result . security . content . blockedPhrases , r . blockedPhrases );
if ( r . disableDownloads ) result . browser . allowDownloads = false ;
if ( r . disableAI ) result . ai . enabled = false ;
if ( r . disableAgents ) result . agents . enabled = false ;
if ( r . hideSettings ) result . browser . settings . visible = false ;
if ( r . requireDocumentScan ) result . inspection . documentAnalysisEnabled = true ;
result . browser . settings . hiddenSections = union ( result . browser . settings . hiddenSections , r . hiddenSections );
result . browser . settings . lockedSections = union ( result . browser . settings . lockedSections , r . lockedSections );
for ( const bookmark of r . managedBookmarks ){ const existing = result . browser . bookmarks . managed . find ( b => b . id === bookmark . id );
if ( existing && JSON . stringify ( existing ) !== JSON . stringify ( bookmark )) throw Error ( 'Managed bookmark ID conflicts across policies' );
if ( ! existing ) result . browser . bookmarks . managed . push ( bookmark );
}
}
result . assignment = { deviceId , groups : applied . map ( g => g . id ),...( result . assignment ? . profiles ? { profiles : result . assignment . profiles } : {})};
return result ;
}