This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user