Files
safetynet/packages/core/bookmarks.mjs
T
2026-10-13 10:34:48 +02:00

18 lines
2.9 KiB
JavaScript

import { z } from 'zod';
export const BookmarkSchema=z.object({id:z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/),title:z.string().trim().min(1).max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Bookmarks require HTTP(S) without credentials, query or fragment').transform(v=>new URL(v).href).refine(v=>v.length<=2048,'Canonical bookmark URL exceeds limit'),folder:z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)).default('')}).strict();
export const BookmarkItemsSchema=z.array(BookmarkSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Bookmark IDs must be unique').refine(items=>Buffer.byteLength(JSON.stringify(items))<=250000,'Bookmark collection exceeds byte budget');
export const PersonalBookmarksSchema=z.object({revision:z.number().int().nonnegative().safe().default(0),items:BookmarkItemsSchema.default([])}).strict().prefault({});
export const BookmarksSchema=z.object({managed:BookmarkItemsSchema.default([]),allowUserAdd:z.boolean().default(true),allowUserEdit:z.boolean().default(true),allowUserDelete:z.boolean().default(true),adminReadUserBookmarks:z.boolean().default(true),personal:PersonalBookmarksSchema}).strict().prefault({}).superRefine((v,ctx)=>{const ids=new Set(v.managed.map(i=>i.id));if(v.personal.items.some(i=>ids.has(i.id)))ctx.addIssue({code:'custom',message:'Managed and personal bookmark IDs must be distinct'});});
import { createPrivateKey,createPublicKey,sign,verify } from 'node:crypto';
export const BookmarkSnapshotSchema=z.object({deviceId:z.string().uuid(),policyVersion:z.number().int().positive(),revision:z.number().int().nonnegative().safe(),expiresAt:z.string().datetime(),items:BookmarkItemsSchema}).strict();
export function signBookmarkSnapshot(payload,privateKey){payload=BookmarkSnapshotSchema.parse(payload);return {payload,signature:sign(null,Buffer.from('SafetyNet:bookmarks:v1\n'+JSON.stringify(payload)),createPrivateKey(privateKey)).toString('base64')};}
export function verifyBookmarkSnapshot(envelope,publicKey,{deviceId,policyVersion,minimumRevision=0,now=Date.now()}){
if(!envelope||typeof envelope.signature!=='string'||envelope.signature.length>200||Buffer.byteLength(JSON.stringify(envelope))>300000)throw Error('Invalid bookmark snapshot');
const payload=BookmarkSnapshotSchema.parse(envelope.payload);
if(JSON.stringify(payload)!==JSON.stringify(envelope.payload)||!verify(null,Buffer.from('SafetyNet:bookmarks:v1\n'+JSON.stringify(payload)),createPublicKey(publicKey),Buffer.from(envelope.signature,'base64')))throw Error('Invalid bookmark signature');
if(payload.deviceId!==deviceId||payload.policyVersion!==policyVersion||payload.revision<minimumRevision||Date.parse(payload.expiresAt)<=now)throw Error('Bookmark snapshot binding, expiry or rollback rejected');
return payload;
}