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(); // eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input. 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; }