Files
DevRunbook-Public/apps/web/src/app/api/v1/playbooks/playbook-query.ts
T
DevRunbook release export cfd2804e27
Managed validation / full (push) Successful in 3m18s
Publish DevRunbook source
2026-09-03 04:09:17 +02:00

180 lines
5.1 KiB
TypeScript

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<PlaybookType>(['quick', 'guided', 'run-pack'])
const modes = new Set<PlaybookMode>([
'inspect',
'plan',
'guided',
'execute',
'recovery',
])
const autonomies = new Set<PlaybookAutonomy>([
'observe',
'diagnose',
'plan',
'implement',
'verify',
'repair',
])
const riskTiers = new Set<PlaybookRiskTier>([
'low',
'moderate',
'high',
'critical',
])
const lifecycles = new Set<PlaybookLifecycle>([
'draft',
'reviewed',
'validated',
'battle-tested',
'deprecated',
])
const sources = new Set<PlaybookSource>(['built_in', 'private', 'imported'])
const qualities = new Set<PlaybookQualityStatus>([
'unreviewed',
'editorial-reviewed',
'technical-reviewed',
'evaluation-backed',
])
const sorts = new Set<PlaybookSort>([
'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<T extends string>(
parameters: URLSearchParams,
name: string,
allowed: ReadonlySet<T>,
): 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 }
}