import { CHUNK_SIZE, EXPIRY_VALUES, GCM_TAG_BYTES, MAX_CHUNK_BODY, MAX_MANIFEST_BODY, MAX_MANIFEST_PLAINTEXT, PROTOCOL_VERSION, READ_VALUES, LEASE_IDLE_MS, UPLOAD_RESERVATION_MS, type ExpiryChoice, type ReadChoice, type ReserveRequest, } from "../types/drop"; export const ROOM_ID_PATTERN = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{32}$/u; export const B64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; const MAX_CHUNKS = 0xfffffffe; export const EXPIRY_MS: Record = { "1h": 3600000, "2h": 7200000, "6h": 21600000, "12h": 43200000, "24h": 86400000, "7d": 604800000, }; export function isExpiry(value: unknown): value is ExpiryChoice { return ( typeof value === "string" && (EXPIRY_VALUES as readonly string[]).includes(value) ); } export function isReadLimit(value: unknown): value is ReadChoice { return ( value === null || (typeof value === "number" && READ_VALUES.includes(value as never)) ); } export function isBase64Url(value: unknown, bytes?: number): value is string { return ( typeof value === "string" && value.length > 0 && value.length <= 512 && B64URL_PATTERN.test(value) && (!bytes || Math.ceil((value.length * 6) / 8) === bytes || value.length === Math.ceil((bytes * 8) / 6)) ); } export function decodeBase64Url(value: string): Uint8Array { if (!B64URL_PATTERN.test(value) || value.length % 4 === 1) throw new Error("Invalid base64url"); const normalized = value .replaceAll("-", "+") .replaceAll("_", "/") .padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(normalized); return Uint8Array.from(binary, (c) => c.charCodeAt(0)); } export function validateRoomId(id: unknown): asserts id is string { if (typeof id !== "string" || !ROOM_ID_PATTERN.test(id)) throw new Error("Invalid drop id"); } export function validateReservation(input: unknown): ReserveRequest { if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Invalid reservation"); const value = input as Record; const allowed = [ "version", "chunkSize", "chunkCount", "noncePrefix", "expiresIn", "maxReads", ]; if (Object.keys(value).some((key) => !allowed.includes(key))) throw new Error("Unknown reservation field"); const chunkCount = value.chunkCount; if ( value.version !== PROTOCOL_VERSION || value.chunkSize !== CHUNK_SIZE || !Number.isSafeInteger(chunkCount) || (chunkCount as number) < 1 || (chunkCount as number) > MAX_CHUNKS ) throw new Error("Invalid chunk geometry"); if ( !isExpiry(value.expiresIn) || !isReadLimit(value.maxReads) || !isBase64Url(value.noncePrefix, 8) ) throw new Error("Invalid reservation policy"); if (decodeBase64Url(value.noncePrefix as string).byteLength !== 8) throw new Error("Invalid nonce prefix"); return value as unknown as ReserveRequest; } export function validateChunkIndex(index: unknown, count: number): number { const parsed = typeof index === "number" ? index : Number(index); if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed >= count) throw new Error("Invalid chunk index"); return parsed; } export function validateChunkBodyLength(length: number, final: boolean): void { if ( !Number.isSafeInteger(length) || length < (final ? GCM_TAG_BYTES : MAX_CHUNK_BODY) || length > MAX_CHUNK_BODY ) throw new Error("Invalid chunk body length"); } export function validateManifestBodyLength(length: number): void { if ( !Number.isSafeInteger(length) || length <= GCM_TAG_BYTES || length > MAX_MANIFEST_BODY ) throw new Error("Invalid manifest body length"); } export function expiresInMs(value: ExpiryChoice): number { return EXPIRY_MS[value]; }