import type { ActorContext, PrivatePlaybookDraft, PrivatePlaybookDraftSummary, } from '@devrunbook/application' import { PlaybookPackageArchiveError } from '@devrunbook/artifacts' import { ContentValidationError } from '@devrunbook/content' import type { PlaybookPackageFileRecord } from '@devrunbook/content' import { handleAuthRequest } from '../../../../auth/csrf' import { AuthenticatedWorkspaceContextError } from '../../../../server/authenticated-workspace-context' import { readPlaybookArchiveBody } from '../playbook-imports/playbook-import-http' const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu export interface PrivatePlaybookHttpService { list(actor: ActorContext): Promise get( actor: ActorContext, versionId: string, ): Promise<{ readonly draft: PrivatePlaybookDraft; readonly etag: string }> update( actor: ActorContext, versionId: string, expectedEtag: string, archive: Uint8Array, ): Promise<{ readonly draft: PrivatePlaybookDraft readonly etag: string readonly archiveSha256: string }> updateFiles( actor: ActorContext, versionId: string, expectedEtag: string, files: readonly PlaybookPackageFileRecord[], ): Promise<{ readonly draft: PrivatePlaybookDraft readonly etag: string readonly packageDigest: string }> export( actor: ActorContext, versionId: string, ): Promise<{ readonly bytes: Uint8Array; readonly sha256: string }> } export interface PrivatePlaybookRouteDependencies { readonly publicBaseUrl: string readonly resolveContext: (request: Request) => Promise readonly service: PrivatePlaybookHttpService } function response( status: number, code: string, message: string, requestId: string, details?: unknown, ) { return Response.json( { error: { code, message, requestId, ...(details === undefined ? {} : { details }), }, }, { status, headers: { 'Cache-Control': 'no-store' } }, ) } function codeOf(error: unknown): string { return error !== null && typeof error === 'object' && 'code' in error ? String(error.code) : '' } function mappedError(error: unknown, requestId: string): Response { if (error instanceof AuthenticatedWorkspaceContextError) { return response( error.code === 'authentication_required' ? 401 : 403, error.code, error.code === 'authentication_required' ? 'Authentication required' : 'Access denied', requestId, ) } if (error instanceof PlaybookPackageArchiveError) { return response( error.code === 'playbook_archive_limit' ? 413 : 422, error.code, 'Playbook package archive verification failed', requestId, [{ path: error.path, message: error.message }], ) } if (error instanceof ContentValidationError) { return response( 422, 'playbook_validation_failed', 'The playbook package failed validation', requestId, error.issues, ) } const code = codeOf(error) if (code === 'workspace_access_denied') return response(403, code, 'Access denied', requestId) if (code === 'private_playbook_not_found') return response(404, code, 'Private playbook not found', requestId) if (code === 'private_playbook_draft_conflict') return response( 409, code, 'The draft changed; reload and review before saving', requestId, ) if (code === 'private_playbook_etag_invalid') return response(422, code, 'The supplied ETag is invalid', requestId) if (code === 'private_playbook_published_immutable') return response( 409, code, 'Published versions are immutable; create a new version', requestId, ) return response( 503, 'private_playbook_service_unavailable', 'Private playbooks are temporarily unavailable', requestId, ) } function assertVersionId(versionId: string): void { if (!uuidPattern.test(versionId)) { throw Object.assign(new Error('Private playbook not found'), { code: 'private_playbook_not_found', }) } } async function readBoundedBody( request: Request, maximumBytes: number, ): Promise { const declared = request.headers.get('content-length') if ( declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > maximumBytes) ) { throw new PlaybookPackageArchiveError( 'playbook_archive_limit', 'body', 'request body limit exceeded', ) } if (!request.body) throw new PlaybookPackageArchiveError( 'playbook_archive_invalid', 'body', 'is required', ) const reader = request.body.getReader() const chunks: Uint8Array[] = [] let size = 0 while (true) { const { done, value } = await reader.read() if (done) break size += value.byteLength if (size > maximumBytes) { await reader.cancel() throw new PlaybookPackageArchiveError( 'playbook_archive_limit', 'body', 'request body limit exceeded', ) } chunks.push(value) } const body = new Uint8Array(size) let offset = 0 for (const chunk of chunks) { body.set(chunk, offset) offset += chunk.byteLength } return body } function plainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } function invalidEditorBody(message: string, path: string): never { throw new ContentValidationError('Invalid editor package', [ { path, code: 'editor_request_invalid', message, remediation: message }, ]) } function base64Bytes(value: string, path: string): Uint8Array { if ( value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test( value, ) ) { invalidEditorBody('Binary file content must be canonical base64.', path) } const decoded = Buffer.from(value, 'base64') if (decoded.toString('base64') !== value) { invalidEditorBody('Binary file content must be canonical base64.', path) } return new Uint8Array(decoded) } async function parseEditorFiles( request: Request, ): Promise { const raw = await readBoundedBody(request, 10 * 1024 * 1024) let parsed: unknown try { parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw)) } catch { invalidEditorBody('Request body must be valid UTF-8 JSON.', '/') } if (!plainObject(parsed) || Object.keys(parsed).join(',') !== 'files') { invalidEditorBody('Request must contain only a files array.', '/') } if (!Array.isArray(parsed.files) || parsed.files.length > 201) { invalidEditorBody('files must contain at most 201 entries.', '/files') } return parsed.files.map((candidate, index) => { const path = `/files/${index}` if (!plainObject(candidate)) invalidEditorBody('File must be an object.', path) const keys = Object.keys(candidate).sort().join(',') if (keys !== 'content,encoding,path,role') { invalidEditorBody( 'File must contain exactly path, role, encoding and content.', path, ) } if ( typeof candidate.path !== 'string' || typeof candidate.role !== 'string' || typeof candidate.content !== 'string' || (candidate.encoding !== 'utf8' && candidate.encoding !== 'base64') ) { invalidEditorBody('File fields have invalid types.', path) } return { path: candidate.path, role: candidate.role, content: candidate.encoding === 'base64' ? base64Bytes(candidate.content, `${path}/content`) : candidate.content, } }) } function encodedContent(content: Uint8Array): { readonly encoding: 'utf8' | 'base64' readonly content: string } { try { return { encoding: 'utf8', content: new TextDecoder('utf-8', { fatal: true }).decode(content), } } catch { return { encoding: 'base64', content: Buffer.from(content).toString('base64'), } } } function detail(draft: PrivatePlaybookDraft) { return { playbookId: draft.playbookId, versionId: draft.versionId, logicalId: draft.logicalId, slug: draft.slug, semanticVersion: draft.semanticVersion, title: draft.title, summary: draft.summary, lifecycle: draft.lifecycle, riskTier: draft.riskTier, category: draft.category, packageApiVersion: draft.packageApiVersion, draftRevision: draft.draftRevision, draftDigest: draft.draftDigest, publishedAt: draft.publishedAt, updatedAt: draft.updatedAt, templateText: draft.templateText, files: draft.files.map((file) => ({ path: file.path, role: file.role, mediaType: file.mediaType, sizeBytes: file.sizeBytes, sha256: file.sha256, digest: file.digest, exportByDefault: file.exportByDefault, ...encodedContent(file.content), })), } } export async function handleListPrivatePlaybooks( request: Request, dependencies: PrivatePlaybookRouteDependencies, ) { const requestId = crypto.randomUUID() try { const actor = await dependencies.resolveContext(request) return Response.json( { items: await dependencies.service.list(actor) }, { headers: { 'Cache-Control': 'no-store' } }, ) } catch (error) { return mappedError(error, requestId) } } export async function handleGetPrivatePlaybook( request: Request, versionId: string, dependencies: PrivatePlaybookRouteDependencies, ) { const requestId = crypto.randomUUID() try { assertVersionId(versionId) const actor = await dependencies.resolveContext(request) const result = await dependencies.service.get(actor, versionId) return Response.json(detail(result.draft), { headers: { ETag: result.etag, 'Cache-Control': 'no-store' }, }) } catch (error) { return mappedError(error, requestId) } } export function handleUpdatePrivatePlaybook( request: Request, versionId: string, dependencies: PrivatePlaybookRouteDependencies, ) { const requestId = crypto.randomUUID() return handleAuthRequest( request, async (sameOriginRequest) => { try { assertVersionId(versionId) const expectedEtag = sameOriginRequest.headers.get('if-match') if (expectedEtag === null) return response( 428, 'private_playbook_precondition_required', 'If-Match is required', requestId, ) const actor = await dependencies.resolveContext(sameOriginRequest) const contentType = sameOriginRequest.headers .get('content-type') ?.split(';', 1)[0] ?.trim() .toLowerCase() ?? '' const result = contentType === 'application/json' ? await dependencies.service.updateFiles( actor, versionId, expectedEtag, await parseEditorFiles(sameOriginRequest), ) : await dependencies.service.update( actor, versionId, expectedEtag, await readPlaybookArchiveBody( sameOriginRequest, 11 * 1024 * 1024, ), ) return Response.json(detail(result.draft), { headers: { ETag: result.etag, ...('archiveSha256' in result ? { 'X-DevRunbook-Archive-SHA256': result.archiveSha256 } : { 'X-DevRunbook-Package-Digest': result.packageDigest }), 'Cache-Control': 'no-store', }, }) } catch (error) { return mappedError(error, requestId) } }, dependencies.publicBaseUrl, () => response(403, 'invalid_origin', 'Invalid request origin', requestId), ) } export async function handleExportPrivatePlaybook( request: Request, versionId: string, dependencies: PrivatePlaybookRouteDependencies, ) { const requestId = crypto.randomUUID() try { assertVersionId(versionId) const actor = await dependencies.resolveContext(request) const result = await dependencies.service.export(actor, versionId) return new Response(result.bytes.slice().buffer, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="playbook-${versionId}.zip"`, 'Content-Length': String(result.bytes.byteLength), 'X-Content-Type-Options': 'nosniff', 'X-DevRunbook-Archive-SHA256': result.sha256, 'Cache-Control': 'no-store', }, }) } catch (error) { return mappedError(error, requestId) } }