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