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