433 lines
12 KiB
TypeScript
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)
|
|
}
|
|
}
|