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

433 lines
12 KiB
TypeScript

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<readonly PrivatePlaybookDraftSummary[]>
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<ActorContext>
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<Uint8Array> {
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<string, unknown> {
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<readonly PlaybookPackageFileRecord[]> {
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)
}
}