213 lines
6.3 KiB
TypeScript
213 lines
6.3 KiB
TypeScript
import { DomainError } from '@devrunbook/domain'
|
|
|
|
import {
|
|
authorizeWorkspaceAction,
|
|
type AuthenticatedActor,
|
|
type WorkspaceAuthorizationLookup,
|
|
} from '../auth/workspace-authorization'
|
|
import {
|
|
assessLifecycleEvidence,
|
|
type LifecycleEvidence,
|
|
type LifecycleEvidencePolicy,
|
|
type PlaybookLifecycle,
|
|
} from '../quality/static-quality-evaluation'
|
|
import {
|
|
formatPrivatePlaybookDraftEtag,
|
|
parsePrivatePlaybookDraftEtag,
|
|
type PrivatePlaybookDraft,
|
|
} from './private-playbook-drafts'
|
|
|
|
const semverPattern =
|
|
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
|
|
|
|
export interface PrivatePlaybookPublicationCandidate {
|
|
readonly draft: PrivatePlaybookDraft
|
|
/** Trusted evidence assembled from persisted validation and evaluation rows. */
|
|
readonly evidence: LifecycleEvidence
|
|
readonly policy: LifecycleEvidencePolicy
|
|
}
|
|
|
|
export interface PrivatePlaybookPublicationStore {
|
|
findPublicationCandidate(
|
|
workspaceId: string,
|
|
versionId: string,
|
|
): Promise<PrivatePlaybookPublicationCandidate | null>
|
|
publishDraft(request: {
|
|
readonly workspaceId: string
|
|
readonly versionId: string
|
|
readonly publishedBy: string
|
|
readonly expectedRevision: number
|
|
readonly expectedDigest: string
|
|
readonly lifecycle: Exclude<PlaybookLifecycle, 'draft' | 'battle-tested'>
|
|
readonly now: Date
|
|
}): Promise<PrivatePlaybookDraft | null>
|
|
createNextDraft(request: {
|
|
readonly workspaceId: string
|
|
readonly sourceVersionId: string
|
|
readonly semanticVersion: string
|
|
readonly createdBy: string
|
|
readonly now: Date
|
|
}): Promise<PrivatePlaybookDraft | null>
|
|
}
|
|
|
|
export interface PrivatePlaybookPublicationDependencies {
|
|
readonly authorization: WorkspaceAuthorizationLookup
|
|
readonly store: PrivatePlaybookPublicationStore
|
|
readonly now: () => Date
|
|
}
|
|
|
|
export interface PrivatePlaybookPublicationRequest {
|
|
readonly actor: AuthenticatedActor | null
|
|
readonly workspaceId: string
|
|
readonly versionId: string
|
|
readonly expectedEtag: string
|
|
readonly lifecycle: Exclude<PlaybookLifecycle, 'draft' | 'battle-tested'>
|
|
}
|
|
|
|
function conflict(message: string, details?: Record<string, unknown>): never {
|
|
throw new DomainError('private_playbook_publish_conflict', message, details)
|
|
}
|
|
|
|
function notFound(): never {
|
|
throw new DomainError(
|
|
'private_playbook_not_found',
|
|
'Private playbook not found',
|
|
)
|
|
}
|
|
|
|
async function authorizeWrite(
|
|
dependencies: PrivatePlaybookPublicationDependencies,
|
|
actor: AuthenticatedActor | null,
|
|
workspaceId: string,
|
|
): Promise<string> {
|
|
const context = await authorizeWorkspaceAction(dependencies.authorization, {
|
|
actor,
|
|
workspaceId,
|
|
action: 'write',
|
|
})
|
|
return context.userId
|
|
}
|
|
|
|
function hasChangelog(draft: PrivatePlaybookDraft): boolean {
|
|
return draft.files.some(
|
|
(file) => file.role === 'changelog' && file.content.byteLength > 0,
|
|
)
|
|
}
|
|
|
|
export async function publishPrivatePlaybookVersion(
|
|
dependencies: PrivatePlaybookPublicationDependencies,
|
|
request: PrivatePlaybookPublicationRequest,
|
|
): Promise<{ readonly version: PrivatePlaybookDraft; readonly etag: string }> {
|
|
const publishedBy = await authorizeWrite(
|
|
dependencies,
|
|
request.actor,
|
|
request.workspaceId,
|
|
)
|
|
const expected = parsePrivatePlaybookDraftEtag(request.expectedEtag)
|
|
const candidate = await dependencies.store.findPublicationCandidate(
|
|
request.workspaceId,
|
|
request.versionId,
|
|
)
|
|
if (!candidate) notFound()
|
|
if (candidate.draft.publishedAt !== null) {
|
|
conflict('Published playbook versions are immutable; create a new version.')
|
|
}
|
|
if (
|
|
candidate.draft.draftRevision !== expected.revision ||
|
|
candidate.draft.draftDigest !== expected.digest
|
|
) {
|
|
conflict('The draft changed since it was reviewed.', {
|
|
currentEtag: formatPrivatePlaybookDraftEtag(
|
|
candidate.draft.draftRevision,
|
|
candidate.draft.draftDigest,
|
|
),
|
|
})
|
|
}
|
|
if (!hasChangelog(candidate.draft)) {
|
|
throw new DomainError(
|
|
'private_playbook_changelog_required',
|
|
'A non-empty changelog is required before publication',
|
|
)
|
|
}
|
|
|
|
const assessment = assessLifecycleEvidence(
|
|
request.lifecycle,
|
|
candidate.evidence,
|
|
candidate.policy,
|
|
dependencies.now(),
|
|
)
|
|
if (!assessment.eligible) {
|
|
throw new DomainError(
|
|
'private_playbook_quality_evidence_insufficient',
|
|
'The requested lifecycle exceeds current quality evidence',
|
|
{ requirements: assessment.requirements, findings: assessment.findings },
|
|
)
|
|
}
|
|
|
|
const version = await dependencies.store.publishDraft({
|
|
workspaceId: request.workspaceId,
|
|
versionId: request.versionId,
|
|
publishedBy,
|
|
expectedRevision: expected.revision,
|
|
expectedDigest: expected.digest,
|
|
lifecycle: request.lifecycle,
|
|
now: dependencies.now(),
|
|
})
|
|
if (!version) notFound()
|
|
return {
|
|
version,
|
|
etag: formatPrivatePlaybookDraftEtag(
|
|
version.draftRevision,
|
|
version.draftDigest,
|
|
),
|
|
}
|
|
}
|
|
|
|
export async function createNextPrivatePlaybookVersion(
|
|
dependencies: PrivatePlaybookPublicationDependencies,
|
|
request: {
|
|
readonly actor: AuthenticatedActor | null
|
|
readonly workspaceId: string
|
|
readonly sourceVersionId: string
|
|
readonly semanticVersion: string
|
|
},
|
|
): Promise<{ readonly draft: PrivatePlaybookDraft; readonly etag: string }> {
|
|
const createdBy = await authorizeWrite(
|
|
dependencies,
|
|
request.actor,
|
|
request.workspaceId,
|
|
)
|
|
if (!semverPattern.test(request.semanticVersion)) {
|
|
throw new DomainError(
|
|
'private_playbook_version_invalid',
|
|
'A valid Semantic Version is required',
|
|
)
|
|
}
|
|
const source = await dependencies.store.findPublicationCandidate(
|
|
request.workspaceId,
|
|
request.sourceVersionId,
|
|
)
|
|
if (!source) notFound()
|
|
if (source.draft.publishedAt === null) {
|
|
conflict('Only an immutable published version can be used as the source.')
|
|
}
|
|
if (source.draft.semanticVersion === request.semanticVersion) {
|
|
conflict('The new version must use a different Semantic Version.')
|
|
}
|
|
const draft = await dependencies.store.createNextDraft({
|
|
workspaceId: request.workspaceId,
|
|
sourceVersionId: request.sourceVersionId,
|
|
semanticVersion: request.semanticVersion,
|
|
createdBy,
|
|
now: dependencies.now(),
|
|
})
|
|
if (!draft) notFound()
|
|
return {
|
|
draft,
|
|
etag: formatPrivatePlaybookDraftEtag(
|
|
draft.draftRevision,
|
|
draft.draftDigest,
|
|
),
|
|
}
|
|
}
|