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 publishDraft(request: { readonly workspaceId: string readonly versionId: string readonly publishedBy: string readonly expectedRevision: number readonly expectedDigest: string readonly lifecycle: Exclude readonly now: Date }): Promise createNextDraft(request: { readonly workspaceId: string readonly sourceVersionId: string readonly semanticVersion: string readonly createdBy: string readonly now: Date }): Promise } 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 } function conflict(message: string, details?: Record): 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 { 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, ), } }