180 lines
5.1 KiB
TypeScript
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 }
|
|
}
|