import type { PlaybookAutonomy, PlaybookCatalogQuery, PlaybookLifecycle, PlaybookMode, PlaybookQualityStatus, PlaybookRiskTier, PlaybookSort, PlaybookSource, PlaybookType, } from '@devrunbook/db' export class PlaybookQueryError extends Error {} const allowedParameters = new Set([ 'q', 'category', 'type', 'mode', 'autonomy', 'riskTier', 'stack', 'lifecycle', 'quality', 'source', 'favorite', 'sort', 'cursor', 'limit', ]) const playbookTypes = new Set(['quick', 'guided', 'run-pack']) const modes = new Set([ 'inspect', 'plan', 'guided', 'execute', 'recovery', ]) const autonomies = new Set([ 'observe', 'diagnose', 'plan', 'implement', 'verify', 'repair', ]) const riskTiers = new Set([ 'low', 'moderate', 'high', 'critical', ]) const lifecycles = new Set([ 'draft', 'reviewed', 'validated', 'battle-tested', 'deprecated', ]) const sources = new Set(['built_in', 'private', 'imported']) const qualities = new Set([ 'unreviewed', 'editorial-reviewed', 'technical-reviewed', 'evaluation-backed', ]) const sorts = new Set([ 'relevance', 'updated', 'title', 'quality', ]) export interface ParsedPlaybookQuery { readonly catalog: PlaybookCatalogQuery readonly cursor?: string readonly limit: number } export function encodePlaybookCursor(offset: number): string { return Buffer.from(`offset:${offset}`, 'utf8').toString('base64url') } export function decodePlaybookCursor(cursor: string | undefined): number { if (!cursor) return 0 const match = /^offset:(\d+)$/u.exec( Buffer.from(cursor, 'base64url').toString('utf8'), ) const offset = match ? Number(match[1]) : Number.NaN if (!Number.isSafeInteger(offset) || offset < 0) { throw new PlaybookQueryError('cursor is malformed') } return offset } function values(parameters: URLSearchParams, name: string): string[] { return parameters .getAll(name) .flatMap((value) => value.split(',')) .map((value) => value.trim()) .filter(Boolean) } function enums( parameters: URLSearchParams, name: string, allowed: ReadonlySet, ): T[] | undefined { const parsed = values(parameters, name) if (parsed.some((value) => !allowed.has(value as T))) { throw new PlaybookQueryError(`${name} contains an unsupported value`) } return parsed.length > 0 ? (parsed as T[]) : undefined } export function parsePlaybookQuery(url: string): ParsedPlaybookQuery { const parameters = new URL(url).searchParams for (const name of parameters.keys()) { if (!allowedParameters.has(name)) { throw new PlaybookQueryError(`Unsupported query parameter: ${name}`) } } const q = parameters.get('q')?.trim() if (q && q.length > 200) throw new PlaybookQueryError('q must be at most 200 characters') const category = values(parameters, 'category') const stack = values(parameters, 'stack') if ([...category, ...stack].some((value) => value.length > 80)) { throw new PlaybookQueryError( 'category and stack values must be at most 80 characters', ) } const type = enums(parameters, 'type', playbookTypes) const mode = enums(parameters, 'mode', modes) const autonomy = enums(parameters, 'autonomy', autonomies) const riskTier = enums(parameters, 'riskTier', riskTiers) const lifecycle = enums(parameters, 'lifecycle', lifecycles) const quality = enums(parameters, 'quality', qualities) const source = enums(parameters, 'source', sources) const sortValues = values(parameters, 'sort') if ( sortValues.length > 1 || (sortValues[0] && !sorts.has(sortValues[0] as PlaybookSort)) ) { throw new PlaybookQueryError('sort contains an unsupported value') } const favoriteValues = values(parameters, 'favorite') if ( favoriteValues.length > 1 || (favoriteValues[0] && !['true', 'false'].includes(favoriteValues[0])) ) { throw new PlaybookQueryError('favorite must be true or false') } const cursor = parameters.get('cursor')?.trim() if (cursor && !/^[A-Za-z0-9_-]{1,500}$/u.test(cursor)) { throw new PlaybookQueryError('cursor is malformed') } if (cursor) decodePlaybookCursor(cursor) const rawLimit = parameters.get('limit') const limit = rawLimit === null ? 50 : Number(rawLimit) if (!Number.isInteger(limit) || limit < 1 || limit > 100) { throw new PlaybookQueryError('limit must be an integer from 1 to 100') } const catalog: PlaybookCatalogQuery = { ...(q ? { q } : {}), ...(category.length > 0 ? { category } : {}), ...(type ? { type } : {}), ...(mode ? { mode } : {}), ...(autonomy ? { autonomy } : {}), ...(riskTier ? { riskTier } : {}), ...(stack.length > 0 ? { stack } : {}), ...(lifecycle ? { lifecycle } : {}), ...(quality ? { quality } : {}), ...(source ? { source } : {}), ...(favoriteValues[0] === 'true' ? { favorite: true } : {}), ...(sortValues[0] ? { sort: sortValues[0] as PlaybookSort } : {}), } return { catalog, ...(cursor ? { cursor } : {}), limit } }