Files
safetynet/packages/core/library.mjs
T

23 lines
2.0 KiB
JavaScript

import {z} from 'zod';
import {createHash} from 'node:crypto';
const id=z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/);
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
const label=z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v));
export const LibraryPolicySchema=z.object({aiSuggestionsEnabled:z.boolean().default(false),aiAutoApplyEnabled:z.boolean().default(false)}).strict().prefault({});
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
export const LibraryTabsSchema=z.array(z.object({id,title:z.string().max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().refine(v=>['https:','http:'].includes(new URL(v).protocol))}).strict()).max(100).refine(v=>new Set(v.map(t=>t.id)).size===v.length);
export const LibraryPlanSchema=z.object({bookmarks:z.array(z.object({id,folder:label}).strict()).max(100),tabs:z.array(z.object({id,group:label.refine(v=>v.length<=40)}).strict()).max(100)}).strict().superRefine((v,c)=>{for(const key of ['bookmarks','tabs'])if(new Set(v[key].map(i=>i.id)).size!==v[key].length)c.addIssue({code:'custom',message:'Duplicate organization IDs'});});
export function librarySnapshotHash(tabs){return createHash('sha256').update(JSON.stringify(LibraryTabsSchema.parse(tabs))).digest('hex');}
export function validateLibraryPlan(value,{bookmarks,tabs}){
const plan=LibraryPlanSchema.parse(value);
for(const [key,items] of [['bookmarks',bookmarks],['tabs',tabs]]){
const ids=new Set(items.map(i=>i.id));
if(plan[key].length!==ids.size||plan[key].some(i=>!ids.has(i.id)))throw Error('Organization must contain each existing item exactly once');
}
return plan;
}
// Query strings, fragments, credentials and path components are never needed
// to group a library. Titles remain untrusted data and are inspected by DLP.
export function libraryMetadata(items){return items.map(({id,title,url})=>({id,title,host:new URL(url).hostname}));}