feat: package atomic reversible nginx releases

This commit is contained in:
2026-09-02 10:27:28 +03:00
parent 8592581b18
commit d3e746b905
17 changed files with 1443 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
import { lstat, mkdtemp, mkdir, readFile, readlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { buildReleasePackage } from '../../scripts/build-release.mjs';
import { activateRelease, cleanupOrphanStages, prepareRelease, readControlState, rollbackRelease } from '../../scripts/deploy/lib/release-transaction.mjs';
import { createReleaseFixture, initializeReleaseRoot } from './release-test-fixture.ts';
type Fixture = Awaited<ReturnType<typeof createReleaseFixture>>;
function buildInput(fixture: Fixture, outputPath: string) {
return {
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
verifierModulePath: fixture.verifierModulePath, verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
trustPolicyPath: fixture.trustPolicyPath, protectedState: fixture.protectedState,
now: '2026-09-02T01:00:00Z', outputPath,
};
}
function transactionInput(root: string, archivePath: string, transactionId: string, fixture: Fixture) {
return {
root, archivePath, transactionId, verifierModulePath: fixture.verifierModulePath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope, trustPolicyPath: fixture.trustPolicyPath,
uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0, now: '2026-09-02T01:00:00Z',
smokeCheck: async ({ releaseDir }: { releaseDir: string }) => {
await readFile(join(releaseDir, 'index.html'));
return Buffer.from('internal-nginx-smoke-ok\n');
},
};
}
describe('fenced atomic Nginx release transaction', () => {
test('prepares immutably without changing current, then activates with one atomic pointer swap', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-atomic-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
await prepareRelease(transactionInput(root, archive, 'tx-a', fixture));
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await activateRelease({ ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
const target = await readlink(join(root, 'current'));
expect(target).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
expect((await lstat(join(root, target, 'index.html'))).mode & 0o777).toBe(0o444);
expect((await lstat(join(root, target))).mode & 0o777).toBe(0o555);
});
test('rejects stale predecessor/fence, expiry crossing, and concurrent activation without moving the old pointer', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-fence-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const input = transactionInput(root, archive, 'tx-a', fixture);
await prepareRelease(input);
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 2, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('fence');
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: fixture.manifestDigest, expectedFence: 1, now: '2026-09-02T01:01:00Z' })).rejects.toThrow('predecessor');
await expect(activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: fixture.expiresAt })).rejects.toThrow('expired');
const attempts = await Promise.allSettled([
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
activateRelease({ ...input, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' }),
]);
expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
expect(attempts.filter((result) => result.status === 'rejected')).toHaveLength(1);
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
});
test('rechecks trusted time after smoke so an expiry crossing never becomes ready', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-expiry-crossing-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const times = ['2026-09-02T01:00:00Z', fixture.expiresAt];
await expect(prepareRelease({ ...transactionInput(root, archive, 'tx-a', fixture), clock: () => times.shift() ?? fixture.expiresAt })).rejects.toThrow('expired while staging');
expect((await readControlState(root)).activeTransaction).toBeNull();
await expect(lstat(join(root, 'current'))).rejects.toThrow();
});
test('a crash before swap leaves the predecessor visible and retry after a post-swap crash converges on the committed release', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-crash-'));
const fixture = await createReleaseFixture(join(temp, 'fixture'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archive = join(temp, 'release.tar'); await buildReleasePackage(buildInput(fixture, archive));
const input = { ...transactionInput(root, archive, 'tx-a', fixture), expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' };
await prepareRelease(input);
await expect(activateRelease({ ...input, crashPoint: 'before-swap' })).rejects.toThrow('before pointer');
await expect(lstat(join(root, 'current'))).rejects.toThrow();
await expect(activateRelease({ ...input, crashPoint: 'after-swap' })).rejects.toThrow('after pointer');
expect(await readlink(join(root, 'current'))).toBe(`releases/release-a-${fixture.manifestDigest}/public`);
const recovered = await activateRelease(input);
expect(recovered.recoveredAfterPointerSwap).toBe(true);
expect((await readControlState(root)).currentManifestDigest).toBe(fixture.manifestDigest);
});
test('rollback requires a fresh signed manifest and never replays the selected old manifest', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-rollback-'));
const fixtureA = await createReleaseFixture(join(temp, 'fixture-a'));
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
const archiveA = join(temp, 'a.tar'); await buildReleasePackage(buildInput(fixtureA, archiveA));
const inputA = transactionInput(root, archiveA, 'tx-a', fixtureA);
await prepareRelease(inputA);
await activateRelease({ ...inputA, expectedCurrentManifestDigest: null, expectedFence: 1, now: '2026-09-02T01:01:00Z' });
const releaseA = (await readlink(join(root, 'current'))).split('/')[1] as string;
const fixtureB = await createReleaseFixture(join(temp, 'fixture-b'));
fixtureB.protectedState.currentManifestDigest = fixtureA.manifestDigest; fixtureB.protectedState.publisherFence = 1;
await writeFile(join(fixtureB.distDir, 'index.html'), '<!doctype html><title>Portfolio B</title>\n');
const signedB = await fixtureB.signFor({ buildId: 'release-b', distDir: fixtureB.distDir, predecessorManifestDigest: fixtureA.manifestDigest, publisherFence: 1, outputPath: fixtureB.signedManifestPath });
const archiveB = join(temp, 'b.tar'); await buildReleasePackage({ ...buildInput(fixtureB, archiveB), now: '2026-09-02T02:00:00Z' });
const inputB = { ...transactionInput(root, archiveB, 'tx-b', fixtureB), now: '2026-09-02T02:00:00Z' };
await prepareRelease(inputB);
await activateRelease({ ...inputB, expectedCurrentManifestDigest: fixtureA.manifestDigest, expectedFence: 2, now: '2026-09-02T02:01:00Z' });
await expect(rollbackRelease({ ...inputA, targetReleaseId: releaseA, transactionId: 'tx-replay', expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' })).rejects.toThrow();
fixtureA.protectedState.currentManifestDigest = signedB.manifestDigest; fixtureA.protectedState.publisherFence = 2;
const freshSigned = join(temp, 'rollback-signed.json');
const fresh = await fixtureA.signFor({ buildId: 'rollback-a', distDir: fixtureA.distDir, predecessorManifestDigest: signedB.manifestDigest, publisherFence: 2, outputPath: freshSigned });
fixtureA.signedManifestPath = freshSigned;
const rollbackArchive = join(temp, 'rollback.tar'); await buildReleasePackage({ ...buildInput(fixtureA, rollbackArchive), now: '2026-09-02T03:00:00Z' });
const rollback = await rollbackRelease({ ...transactionInput(root, rollbackArchive, 'tx-rollback', fixtureA), targetReleaseId: releaseA,
expectedCurrentManifestDigest: signedB.manifestDigest, expectedFence: 3, now: '2026-09-02T03:00:00Z' });
expect(rollback.manifestDigest).toBe(fresh.manifestDigest);
expect(await readlink(join(root, 'current'))).toContain('rollback-a-');
});
test('orphan cleanup and all mutations remain confined to a validated explicit release root', async () => {
const temp = await mkdtemp(join(tmpdir(), 'portfolio-cleanup-'));
const outside = join(temp, 'outside.txt'); await writeFile(outside, 'keep');
const invalidRoot = join(temp, 'not-a-release-root'); await mkdir(join(invalidRoot, 'staging', 'orphan.partial'), { recursive: true });
await expect(cleanupOrphanStages(invalidRoot)).rejects.toThrow('sentinel');
expect(await readFile(outside, 'utf8')).toBe('keep');
const root = join(temp, 'release-root'); await initializeReleaseRoot(root);
await mkdir(join(root, 'staging', 'orphan.partial'), { recursive: true });
await writeFile(join(root, 'staging', 'orphan.partial', 'marker'), 'orphan');
expect(await cleanupOrphanStages(root)).toEqual(['orphan.partial']);
await expect(lstat(join(root, 'staging', 'orphan.partial'))).rejects.toThrow();
expect(await readFile(outside, 'utf8')).toBe('keep');
await expect(cleanupOrphanStages('/')).rejects.toThrow('Broad');
});
});

View File

@@ -0,0 +1,51 @@
import { mkdtemp, readFile, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'vitest';
import { byteDigest, canonicalValueBytes } from '../../src/security/canonical-digest.ts';
import { buildReleasePackage } from '../../scripts/build-release.mjs';
import { parseDeterministicTar } from '../../scripts/deploy/lib/release-common.mjs';
import { createReleaseFixture } from './release-test-fixture.ts';
function buildInput(fixture: Awaited<ReturnType<typeof createReleaseFixture>>, outputPath: string) {
return {
distDir: fixture.distDir, signedManifestPath: fixture.signedManifestPath, sbomPath: fixture.sbomPath,
verifierModulePath: fixture.verifierModulePath, trustPolicyPath: fixture.trustPolicyPath,
verifySignedReleaseEnvelope: fixture.verifySignedReleaseEnvelope,
protectedState: fixture.protectedState, now: '2026-09-02T01:00:00Z', outputPath,
};
}
describe('deterministic exact-manifest release package', () => {
test('builds byte-identical archives containing only the signed closure and release metadata', async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-'));
const fixture = await createReleaseFixture(root);
const first = join(root, 'release-one.tar');
const second = join(root, 'release-two.tar');
await buildReleasePackage(buildInput(fixture, first));
await buildReleasePackage(buildInput(fixture, second));
expect(byteDigest(await readFile(first))).toBe(byteDigest(await readFile(second)));
expect(parseDeterministicTar(await readFile(first)).map((file) => file.path)).toEqual([
'.release/package-index.json', '.release/sbom.cdx.json', '.release/signed-release-manifest.json',
'assets/app.js', 'index.html',
]);
});
test('fails closed on extra, substituted, or invalidly signed inputs', async () => {
const root = await mkdtemp(join(tmpdir(), 'portfolio-package-invalid-'));
const fixture = await createReleaseFixture(root);
await writeFile(join(fixture.distDir, 'extra.txt'), 'not in manifest');
await expect(buildReleasePackage(buildInput(fixture, join(root, 'extra.tar')))).rejects.toThrow('exactly match');
await unlink(join(fixture.distDir, 'extra.txt'));
await writeFile(join(fixture.distDir, 'index.html'), 'substituted');
await expect(buildReleasePackage(buildInput(fixture, join(root, 'substituted.tar')))).rejects.toThrow('hash');
await writeFile(join(fixture.distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
const envelope = JSON.parse(await readFile(fixture.signedManifestPath, 'utf8'));
envelope.signature.value = `${envelope.signature.value[0] === 'A' ? 'B' : 'A'}${envelope.signature.value.slice(1)}`;
await writeFile(fixture.signedManifestPath, canonicalValueBytes(envelope));
await expect(buildReleasePackage(buildInput(fixture, join(root, 'invalid.tar')))).rejects.toThrow('signature');
});
});

View File

@@ -0,0 +1,197 @@
import { generateKeyPairSync, sign, verify } from 'node:crypto';
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { canonicalJsonDigest, canonicalValueBytes, byteDigest } from '../../src/security/canonical-digest.ts';
import { computeProductionInputRoot } from '../../src/security/production-input-root.ts';
const digest = (character: string) => character.repeat(64);
export interface ReleaseFixture {
distDir: string;
signedManifestPath: string;
sbomPath: string;
trustPolicyPath: string;
verifierModulePath: string;
verifySignedReleaseEnvelope(input: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }): any;
protectedState: {
latestLedgerHeadHash: string;
latestLedgerRevision: number;
currentManifestDigest: string | null;
publisherFence: number;
};
manifestDigest: string;
buildId: string;
expiresAt: string;
signFor(input: {
buildId: string;
distDir: string;
predecessorManifestDigest: string | null;
publisherFence: number;
issuedAt?: string;
expiresAt?: string;
outputPath: string;
}): Promise<{ manifestDigest: string; signedManifestPath: string }>;
}
export async function createReleaseFixture(root: string): Promise<ReleaseFixture> {
const distDir = join(root, 'dist');
await mkdir(join(distDir, 'assets'), { recursive: true });
await writeFile(join(distDir, 'index.html'), '<!doctype html><title>Portfolio</title>\n');
await writeFile(join(distDir, 'assets/app.js'), 'console.log("portfolio");\n');
const sbomPath = join(root, 'sbom.cdx.json');
await writeFile(sbomPath, '{"bomFormat":"CycloneDX","specVersion":"1.6"}\n');
const keys = generateKeyPairSync('ed25519');
const publicKeyPem = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString();
const trustPolicyPath = join(root, 'release-trust.json');
await writeFile(trustPolicyPath, canonicalValueBytes({
keyId: 'release-test-key', issuerEpoch: 7, scope: 'portfolio-release-signing/v1', publicKeyPem,
}));
const verifierModulePath = join(root, 'real-release-verifier.mjs');
await writeFile(verifierModulePath, `
import { createHash, verify } from 'node:crypto';
function canonical(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
}
function digest(schema, value) {
return createHash('sha256').update(canonical({ schema, value })).digest('hex');
}
function fail(message) { throw new Error(message); }
export function verifySignedReleaseEnvelope({ signedRelease, trustPolicy, now, protectedState }) {
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') fail('bad envelope schema');
if (signedRelease.signature?.algorithm !== 'Ed25519' || signedRelease.signature?.encoding !== 'base64url-nopad') fail('bad signature metadata');
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch || signedRelease.signature?.scope !== trustPolicy.scope) fail('untrusted signer');
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
if (signature.length !== 64 || !verify(null, Buffer.from(canonical(signedRelease.payload)), trustPolicy.publicKeyPem, signature)) fail('invalid release signature');
const manifestDigest = digest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
if (manifestDigest !== signedRelease.payloadDigest) fail('candidate digest mismatch');
const candidate = signedRelease.payload;
if (candidate.latestLedgerHeadHash !== protectedState.latestLedgerHeadHash || candidate.latestLedgerRevision !== protectedState.latestLedgerRevision) fail('stale ledger');
if (candidate.releaseManifest.currentManifestDigest !== protectedState.currentManifestDigest) fail('stale predecessor');
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) fail('expired release');
return {
schema: 'portfolio-verified-release-admission/v1',
manifestDigest,
manifestPayload: candidate.releaseManifest,
expiresAt: candidate.releaseManifest.expiresAt,
predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
latestLedgerHeadHash: candidate.latestLedgerHeadHash,
latestLedgerRevision: candidate.latestLedgerRevision,
expectedFence: protectedState.publisherFence + 1,
signatureVerified: true,
};
}
`);
await chmod(verifierModulePath, 0o500);
const protectedState = {
latestLedgerHeadHash: digest('9'), latestLedgerRevision: 11, currentManifestDigest: null, publisherFence: 0,
};
const verifySignedReleaseEnvelope = ({ signedRelease, trustPolicy, now, protectedState: state }: { signedRelease: any; trustPolicy: any; now: string; protectedState: any }) => {
if (signedRelease?.schema !== 'portfolio-signed-release-manifest/v1') throw new Error('bad envelope schema');
if (signedRelease.signature?.keyId !== trustPolicy.keyId || signedRelease.signature?.issuerEpoch !== trustPolicy.issuerEpoch ||
signedRelease.signature?.scope !== trustPolicy.scope) throw new Error('untrusted signer');
const signature = Buffer.from(signedRelease.signature.value, 'base64url');
if (signature.length !== 64 || !verify(null, canonicalValueBytes(signedRelease.payload), trustPolicy.publicKeyPem, signature)) throw new Error('invalid release signature');
const manifestDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', signedRelease.payload);
if (manifestDigest !== signedRelease.payloadDigest) throw new Error('candidate digest mismatch');
const candidate = signedRelease.payload;
if (candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash || candidate.latestLedgerRevision !== state.latestLedgerRevision) throw new Error('stale ledger');
if (candidate.releaseManifest.currentManifestDigest !== state.currentManifestDigest) throw new Error('stale predecessor');
if (Date.parse(now) >= Date.parse(candidate.releaseManifest.expiresAt)) throw new Error('expired release');
return {
schema: 'portfolio-verified-release-admission/v1', manifestDigest, manifestPayload: candidate.releaseManifest,
expiresAt: candidate.releaseManifest.expiresAt, predecessorManifestDigest: candidate.releaseManifest.currentManifestDigest,
latestLedgerHeadHash: candidate.latestLedgerHeadHash, latestLedgerRevision: candidate.latestLedgerRevision,
expectedFence: state.publisherFence + 1, signatureVerified: true,
};
};
const signFor = async (input: {
buildId: string;
distDir: string;
predecessorManifestDigest: string | null;
publisherFence: number;
issuedAt?: string;
expiresAt?: string;
outputPath: string;
}) => {
const artifactPaths = ['assets/app.js', 'index.html'];
const artifacts = await Promise.all(artifactPaths.map(async (path) => {
const bytes = await readFile(join(input.distDir, path));
return { path, sha256: byteDigest(bytes), bytes: bytes.length, encoding: 'identity' as const };
}));
const inputDigests = {
PortfolioContent: digest('a'), PublicAssetManifest: digest('b'), PreparedAssetSet: digest('c'), SiteConfig: digest('d'),
};
const issuedAt = input.issuedAt ?? '2026-09-02T00:00:00Z';
const expiresAt = input.expiresAt ?? '2026-09-03T00:00:00Z';
const releaseManifest = {
schema: 'portfolio-release-manifest/v1' as const,
launchMode: 'private-source-initial-launch' as const,
buildId: input.buildId,
inputDigests,
productionInputRoot: computeProductionInputRoot({
portfolioContentDigest: inputDigests.PortfolioContent,
publicAssetManifestDigest: inputDigests.PublicAssetManifest,
preparedAssetSetDigest: inputDigests.PreparedAssetSet,
siteConfigDigest: inputDigests.SiteConfig,
}),
artifacts,
deployFiles: artifactPaths,
sourceCommit: digest('e'), sourceTree: digest('f'), approvedRecordRoot: digest('1'), approvedAssetRoot: digest('2'),
approvalProofSetDigest: digest('3'), approvalProjectionAttestationDigest: digest('4'), scanEvidenceDigest: digest('5'),
policyVersions: { approval: '1', privacy: '1', release: '1' }, toolVersions: { node: '22.22.1' },
predecessorManifestDigest: input.predecessorManifestDigest,
currentManifestDigest: input.predecessorManifestDigest,
issuedAt, expiresAt, minimumBoundExpiry: expiresAt,
};
const candidate = {
schema: 'portfolio-candidate-release-manifest/v1',
releaseManifest,
latestLedgerHeadHash: protectedState.latestLedgerHeadHash,
latestLedgerRevision: protectedState.latestLedgerRevision,
sourceClosureDigest: digest('6'), publicRefMap: [], lfsOids: [], releaseArchives: [],
buildProvenanceDigest: digest('7'), privacyPolicyDigest: digest('8'), toolImageDigests: [digest('0')],
};
const payloadDigest = canonicalJsonDigest('portfolio-candidate-release-manifest/v1', candidate);
const signature = sign(null, canonicalValueBytes(candidate), keys.privateKey).toString('base64url');
const signedRelease = {
schema: 'portfolio-signed-release-manifest/v1', payload: candidate, payloadDigest,
signature: {
algorithm: 'Ed25519', encoding: 'base64url-nopad', keyId: 'release-test-key', issuerEpoch: 7,
scope: 'portfolio-release-signing/v1', value: signature,
},
};
await writeFile(input.outputPath, canonicalValueBytes(signedRelease));
return { manifestDigest: payloadDigest, signedManifestPath: input.outputPath };
};
const signedManifestPath = join(root, 'signed-release.json');
const initial = await signFor({ buildId: 'release-a', distDir, predecessorManifestDigest: null, publisherFence: 0, outputPath: signedManifestPath });
return {
distDir, signedManifestPath, sbomPath, trustPolicyPath, verifierModulePath, verifySignedReleaseEnvelope, protectedState,
manifestDigest: initial.manifestDigest, buildId: 'release-a', expiresAt: '2026-09-03T00:00:00Z', signFor,
};
}
export async function initializeReleaseRoot(root: string, state?: Partial<ReleaseFixture['protectedState']>): Promise<void> {
await mkdir(join(root, 'control'), { recursive: true });
await writeFile(join(root, '.portfolio-release-root'), 'portfolio-release-root/v1\n');
await writeFile(join(root, 'control/state.json'), canonicalValueBytes({
schema: 'portfolio-release-control-state/v1',
currentManifestDigest: state?.currentManifestDigest ?? null,
currentReleaseId: null,
publisherFence: state?.publisherFence ?? 0,
latestLedgerHeadHash: state?.latestLedgerHeadHash ?? digest('9'),
latestLedgerRevision: state?.latestLedgerRevision ?? 11,
activeTransaction: null,
}));
}