fix: enforce approved media publication closure

This commit is contained in:
2026-09-02 08:15:00 +03:00
parent a1520f2783
commit 5edddb8781
6 changed files with 283 additions and 31 deletions

View File

@@ -19,8 +19,8 @@
"verify:assets": "node scripts/prepare-assets.mjs --verify", "verify:assets": "node scripts/prepare-assets.mjs --verify",
"check:budgets": "node scripts/check-bundle-budgets.mjs dist", "check:budgets": "node scripts/check-bundle-budgets.mjs dist",
"aggregate:performance": "node scripts/aggregate-drive-traces.mjs", "aggregate:performance": "node scripts/aggregate-drive-traces.mjs",
"prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs", "verify:approved-assets": "node scripts/materialize-empty-asset-set.mjs",
"build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets" "build:preview": "npm run verify:approved-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View File

@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import { constants } from 'node:fs'; import { constants } from 'node:fs';
import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises'; import { lstat, mkdir, open, readFile, readdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const scriptPath = fileURLToPath(import.meta.url); const scriptPath = fileURLToPath(import.meta.url);
@@ -50,16 +51,103 @@ const assertOpenedDirectoryIdentity = async (preparedAssetsPath, expectedIdentit
} }
}; };
const readEmptyManifest = async (repositoryRoot) => { const readJson = async (path, label) => {
let value;
try {
value = JSON.parse(await readFile(path, 'utf8'));
} catch (error) {
throw new Error(`${label} must be valid JSON.`, { cause: error });
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${label} must be a JSON object.`);
}
return value;
};
const readManifest = async (repositoryRoot) => {
const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json'); const manifestPath = resolve(repositoryRoot, 'content/public/asset-manifest.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); const manifest = await readJson(manifestPath, 'Public asset manifest');
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest) || if (Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets)) {
Object.keys(manifest).length !== 1 || !Array.isArray(manifest.assets) || manifest.assets.length !== 0) { throw new Error('Public asset manifest must contain exactly one assets array.');
throw new Error('Phase 1 asset manifest must contain exactly zero assets.'); }
const ids = new Set();
const fileNames = new Set();
const assets = manifest.assets.map((asset) => {
if (typeof asset !== 'object' || asset === null || Array.isArray(asset)) {
throw new Error('Every manifest asset must be an object.');
}
const { id, fileName, sha256 } = asset;
if (typeof id !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(id)) {
throw new Error('Manifest asset ID must be a generated safe identifier.');
}
if (ids.has(id)) throw new Error(`Duplicate manifest asset ID: ${id}`);
ids.add(id);
if (typeof fileName !== 'string' || !/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/u.test(fileName)) {
throw new Error(`Manifest filename must be a generated safe filename: ${id}`);
}
const foldedFileName = fileName.toLocaleLowerCase('en-US');
if (fileNames.has(foldedFileName)) throw new Error(`Duplicate manifest filename: ${fileName}`);
fileNames.add(foldedFileName);
if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(sha256)) {
throw new Error(`Manifest asset must have a lowercase SHA-256 digest: ${id}`);
}
return { id, fileName, sha256 };
});
return assets;
};
const assetReferences = (content) => {
const references = [];
const projects = content.projects;
if (typeof projects !== 'object' || projects === null || Array.isArray(projects)) {
throw new Error('Portfolio content must contain project collections.');
}
for (const collectionName of ['selected', 'additional']) {
const collection = projects[collectionName];
if (!Array.isArray(collection)) throw new Error(`Portfolio ${collectionName} projects must be an array.`);
for (const project of collection) {
if (typeof project !== 'object' || project === null || Array.isArray(project) || !Array.isArray(project.assetIds)) {
throw new Error('Every public project must contain an assetIds array.');
}
references.push(...project.assetIds);
}
}
if (!Array.isArray(content.toolkit)) throw new Error('Portfolio toolkit must be an array.');
for (const group of content.toolkit) {
if (typeof group !== 'object' || group === null || Array.isArray(group) || !Array.isArray(group.technologies)) {
throw new Error('Every toolkit group must contain a technologies array.');
}
for (const technology of group.technologies) {
if (typeof technology !== 'object' || technology === null || Array.isArray(technology)) {
throw new Error('Every toolkit technology must be an object.');
}
if (technology.assetId !== undefined) references.push(technology.assetId);
}
}
if (typeof content.metadata !== 'object' || content.metadata === null || Array.isArray(content.metadata)) {
throw new Error('Portfolio content must contain metadata.');
}
if (content.metadata.socialPreviewAssetId !== undefined) references.push(content.metadata.socialPreviewAssetId);
return references;
};
const assertExactReferenceClosure = (assets, content) => {
const manifestIds = new Set(assets.map((asset) => asset.id));
const referencedIds = new Set();
for (const assetId of assetReferences(content)) {
if (typeof assetId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(assetId)) {
throw new Error('Public asset reference must be a generated safe identifier.');
}
if (referencedIds.has(assetId)) throw new Error(`Duplicate public asset reference: ${assetId}`);
referencedIds.add(assetId);
if (!manifestIds.has(assetId)) throw new Error(`Unknown public asset reference: ${assetId}`);
}
for (const assetId of manifestIds) {
if (!referencedIds.has(assetId)) throw new Error(`Unreferenced manifest asset: ${assetId}`);
} }
}; };
const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => { const ensurePreparedAssetDirectory = async (repositoryRoot) => {
const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets'); const preparedAssetsPath = resolve(repositoryRoot, 'inputs/prepared-assets');
let initialStats; let initialStats;
try { try {
@@ -74,19 +162,72 @@ const ensureEmptyPreparedAssetDirectory = async (repositoryRoot) => {
const initialIdentity = directoryIdentity(initialStats); const initialIdentity = directoryIdentity(initialStats);
await assertOpenedDirectoryIdentity(preparedAssetsPath, initialIdentity); await assertOpenedDirectoryIdentity(preparedAssetsPath, initialIdentity);
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity); await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
const members = await readdir(preparedAssetsPath); return { preparedAssetsPath, initialIdentity };
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity); };
if (members.length !== 0) {
throw new Error('Prepared asset directory must be empty during Phase 1.'); const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const readPreparedRegularFile = async (filePath, fileName) => {
const noFollowFlags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
let handle;
try {
handle = await open(filePath, noFollowFlags);
} catch (error) {
if (isErrno(error, 'ELOOP')) {
throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`);
}
throw error;
}
try {
const stats = await handle.stat();
if (!stats.isFile() || stats.nlink !== 1) {
throw new Error(`Prepared asset must be a regular non-linked file: ${fileName}`);
}
return await handle.readFile();
} finally {
await handle.close();
} }
}; };
export const materializeEmptyAssetSet = async (repositoryRoot) => { const verifyPreparedAssetFiles = async (repositoryRoot, assets) => {
const resolvedRepositoryRoot = resolve(repositoryRoot); const { preparedAssetsPath, initialIdentity } = await ensurePreparedAssetDirectory(repositoryRoot);
await readEmptyManifest(resolvedRepositoryRoot); const expectedByName = new Map(assets.map((asset) => [asset.fileName, asset]));
await ensureEmptyPreparedAssetDirectory(resolvedRepositoryRoot); const foundNames = new Set();
const members = await readdir(preparedAssetsPath, { withFileTypes: true });
for (const member of members) {
const asset = expectedByName.get(member.name);
if (asset === undefined) throw new Error(`Extra prepared asset: ${member.name}`);
if (member.isSymbolicLink() || !member.isFile()) {
throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`);
}
const filePath = join(preparedAssetsPath, member.name);
const stats = await lstat(filePath);
if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink !== 1) {
throw new Error(`Prepared asset must be a regular non-linked file: ${member.name}`);
}
if (sha256(await readPreparedRegularFile(filePath, member.name)) !== asset.sha256) {
throw new Error(`Prepared asset hash does not match manifest: ${member.name}`);
}
foundNames.add(member.name);
}
await assertPreparedAssetDirectoryIdentity(preparedAssetsPath, initialIdentity);
for (const asset of assets) {
if (!foundNames.has(asset.fileName)) throw new Error(`Missing prepared asset: ${asset.fileName}`);
}
}; };
export const verifyApprovedAssetSet = async (repositoryRoot) => {
const resolvedRepositoryRoot = resolve(repositoryRoot);
const [assets, content] = await Promise.all([
readManifest(resolvedRepositoryRoot),
readJson(resolve(resolvedRepositoryRoot, 'content/public/portfolio-content.json'), 'Portfolio content'),
]);
assertExactReferenceClosure(assets, content);
await verifyPreparedAssetFiles(resolvedRepositoryRoot, assets);
};
export const materializeEmptyAssetSet = verifyApprovedAssetSet;
if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) {
await materializeEmptyAssetSet(defaultRepositoryRoot); await verifyApprovedAssetSet(defaultRepositoryRoot);
} }

View File

@@ -297,11 +297,21 @@ export const parseProductionInputSnapshot = (input: unknown): ProductionInputs =
technology.assetId === undefined ? [] : [technology.assetId])), technology.assetId === undefined ? [] : [technology.assetId])),
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]), ...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
]; ];
const seenReferences = new Set<string>();
for (const assetId of referencedAssetIds) { for (const assetId of referencedAssetIds) {
if (seenReferences.has(assetId)) {
throw new Error(`Duplicate public asset reference: ${assetId}`);
}
seenReferences.add(assetId);
if (!manifestIds.has(assetId)) { if (!manifestIds.has(assetId)) {
throw new Error(`Unmanifested asset reference: ${assetId}`); throw new Error(`Unmanifested asset reference: ${assetId}`);
} }
} }
for (const assetId of manifestIds) {
if (!seenReferences.has(assetId)) {
throw new Error(`Unreferenced manifest asset: ${assetId}`);
}
}
return { content, manifest, site } as ProductionInputs; return { content, manifest, site } as ProductionInputs;
}; };

View File

@@ -1,10 +1,11 @@
import { createHash } from 'node:crypto';
import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { import {
assertPreparedAssetDirectoryIdentity, assertPreparedAssetDirectoryIdentity,
materializeEmptyAssetSet, verifyApprovedAssetSet,
} from '../../scripts/materialize-empty-asset-set.mjs'; } from '../../scripts/materialize-empty-asset-set.mjs';
const temporaryRoots = []; const temporaryRoots = [];
@@ -14,35 +15,115 @@ const createScratchRepository = async () => {
temporaryRoots.push(root); temporaryRoots.push(root);
await mkdir(join(root, 'content/public'), { recursive: true }); await mkdir(join(root, 'content/public'), { recursive: true });
await writeFile(join(root, 'content/public/asset-manifest.json'), '{"assets":[]}'); await writeFile(join(root, 'content/public/asset-manifest.json'), '{"assets":[]}');
await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({
projects: { selected: [], additional: [] },
toolkit: [],
metadata: {},
}));
return root; return root;
}; };
const assetBytes = Buffer.from('approved public asset');
const assetHash = createHash('sha256').update(assetBytes).digest('hex');
const writeApprovedSet = async (root) => {
await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ assets: [{
id: 'approved-image',
fileName: 'approved-image.webp',
sha256: assetHash,
}] }));
await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({
projects: { selected: [{ assetIds: ['approved-image'] }], additional: [] },
toolkit: [],
metadata: {},
}));
await mkdir(join(root, 'inputs/prepared-assets'), { recursive: true });
await writeFile(join(root, 'inputs/prepared-assets/approved-image.webp'), assetBytes);
};
afterEach(async () => { afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
}); });
describe('empty asset materialization', () => { describe('approved asset publication closure', () => {
it('creates an empty prepared-asset directory in the supplied repository root', async () => { it('creates an empty prepared-asset directory in the supplied repository root', async () => {
const root = await createScratchRepository(); const root = await createScratchRepository();
await materializeEmptyAssetSet(root); await verifyApprovedAssetSet(root);
expect((await lstat(join(root, 'inputs/prepared-assets'))).isDirectory()).toBe(true); expect((await lstat(join(root, 'inputs/prepared-assets'))).isDirectory()).toBe(true);
}); });
it('accepts an approved manifest, public reference, and prepared file with exact closure', async () => {
const root = await createScratchRepository();
await writeApprovedSet(root);
await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined();
});
it('rejects a symbolic-link prepared-asset directory', async () => { it('rejects a symbolic-link prepared-asset directory', async () => {
const root = await createScratchRepository(); const root = await createScratchRepository();
const target = join(root, 'outside'); const target = join(root, 'outside');
await mkdir(target); await mkdir(target);
await mkdir(join(root, 'inputs'), { recursive: true }); await mkdir(join(root, 'inputs'), { recursive: true });
await symlink(target, join(root, 'inputs/prepared-assets')); await symlink(target, join(root, 'inputs/prepared-assets'));
await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/non-symlink directory/i); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/non-symlink directory/i);
}); });
it('rejects unexpected prepared-asset members in its supplied repository root', async () => { it('rejects extra prepared files', async () => {
const root = await createScratchRepository(); const root = await createScratchRepository();
const preparedAssetsPath = join(root, 'inputs/prepared-assets'); const preparedAssetsPath = join(root, 'inputs/prepared-assets');
await mkdir(preparedAssetsPath, { recursive: true }); await mkdir(preparedAssetsPath, { recursive: true });
await writeFile(join(preparedAssetsPath, 'unexpected.webp'), 'not an asset'); await writeFile(join(preparedAssetsPath, 'unexpected.webp'), 'not an asset');
await expect(materializeEmptyAssetSet(root)).rejects.toThrow(/must be empty/i); await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/extra prepared asset/i);
});
it('rejects missing and path-mismatched prepared files', async () => {
const root = await createScratchRepository();
await writeApprovedSet(root);
await rm(join(root, 'inputs/prepared-assets/approved-image.webp'));
await writeFile(join(root, 'inputs/prepared-assets/wrong-image.webp'), assetBytes);
await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/extra prepared asset|missing prepared asset/i);
});
it('rejects unknown, unreferenced, and duplicate publication IDs', async () => {
const unknown = await createScratchRepository();
await writeApprovedSet(unknown);
await writeFile(join(unknown, 'content/public/portfolio-content.json'), JSON.stringify({
projects: { selected: [{ assetIds: ['unknown-image'] }], additional: [] }, toolkit: [], metadata: {},
}));
await expect(verifyApprovedAssetSet(unknown)).rejects.toThrow(/unknown public asset reference/i);
const unreferenced = await createScratchRepository();
await writeApprovedSet(unreferenced);
await writeFile(join(unreferenced, 'content/public/portfolio-content.json'), JSON.stringify({
projects: { selected: [], additional: [] }, toolkit: [], metadata: {},
}));
await expect(verifyApprovedAssetSet(unreferenced)).rejects.toThrow(/unreferenced manifest asset/i);
const duplicate = await createScratchRepository();
await writeApprovedSet(duplicate);
await writeFile(join(duplicate, 'content/public/portfolio-content.json'), JSON.stringify({
projects: { selected: [{ assetIds: ['approved-image', 'approved-image'] }], additional: [] },
toolkit: [], metadata: {},
}));
await expect(verifyApprovedAssetSet(duplicate)).rejects.toThrow(/duplicate public asset reference/i);
});
it('rejects duplicate manifest IDs', async () => {
const root = await createScratchRepository();
await writeApprovedSet(root);
const duplicateAsset = { id: 'approved-image', fileName: 'approved-image.webp', sha256: assetHash };
await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({
assets: [duplicateAsset, { ...duplicateAsset, fileName: 'second-image.webp' }],
}));
await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/duplicate manifest asset id/i);
});
it('rejects duplicate manifest filenames', async () => {
const root = await createScratchRepository();
await writeApprovedSet(root);
const duplicateAsset = { id: 'approved-image', fileName: 'approved-image.webp', sha256: assetHash };
await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({
assets: [duplicateAsset, { ...duplicateAsset, id: 'second-image' }],
}));
await expect(verifyApprovedAssetSet(root)).rejects.toThrow(/duplicate manifest filename/i);
}); });
it('rejects a prepared-asset directory replaced after its identity was captured', async () => { it('rejects a prepared-asset directory replaced after its identity was captured', async () => {

View File

@@ -1,6 +1,6 @@
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parsePortfolioContent, parsePublicAssetManifest, parseSiteConfig } from '@/content/schemas'; import { validateProductionInputs } from '@/content/schemas';
const repositoryRoot = process.cwd(); const repositoryRoot = process.cwd();
const readJson = async (relativePath: string): Promise<unknown> => const readJson = async (relativePath: string): Promise<unknown> =>
@@ -8,9 +8,12 @@ const readJson = async (relativePath: string): Promise<unknown> =>
describe('approved public portfolio projection', () => { describe('approved public portfolio projection', () => {
it('contains approved identity and balanced evidence without prohibited surfaces', async () => { it('contains approved identity and balanced evidence without prohibited surfaces', async () => {
const content = parsePortfolioContent(await readJson('content/public/portfolio-content.json')); const { content, manifest, site } = validateProductionInputs(
const manifest = parsePublicAssetManifest(await readJson('content/public/asset-manifest.json')); await readJson('content/public/portfolio-content.json'),
const site = parseSiteConfig(await readJson('config/site.preview.json')); await readJson('content/public/asset-manifest.json'),
await readJson('config/site.preview.json'),
new URL('file:///prepared-assets/'),
);
expect(content.identity).toEqual(expect.objectContaining({ expect(content.identity).toEqual(expect.objectContaining({
name: 'Luca Pîslaru', name: 'Luca Pîslaru',
@@ -25,8 +28,6 @@ describe('approved public portfolio projection', () => {
.toBe('Private self-hosted medication tracker'); .toBe('Private self-hosted medication tracker');
expect(content.projects.additional.find((project) => project.id === 'private-game-prototype')?.title) expect(content.projects.additional.find((project) => project.id === 'private-game-prototype')?.title)
.toBe('Private game prototype'); .toBe('Private game prototype');
expect([...content.projects.selected, ...content.projects.additional]
.every((project) => project.assetIds.length === 0)).toBe(true);
expect(content.toolkit.flatMap((group) => group.technologies).map((technology) => technology.name)) expect(content.toolkit.flatMap((group) => group.technologies).map((technology) => technology.name))
.toEqual(expect.arrayContaining(['Linux', 'Docker', 'Python', 'Vue.js', 'Java'])); .toEqual(expect.arrayContaining(['Linux', 'Docker', 'Python', 'Vue.js', 'Java']));
expect(content.contact).toEqual(expect.objectContaining({ email: 'luca.pislaru@gmail.com' })); expect(content.contact).toEqual(expect.objectContaining({ email: 'luca.pislaru@gmail.com' }));
@@ -35,7 +36,14 @@ describe('approved public portfolio projection', () => {
'https://gitlab.com/luca_pisl', 'https://gitlab.com/luca_pisl',
'https://linkedin.com/in/luca-pisl', 'https://linkedin.com/in/luca-pisl',
]); ]);
expect(manifest.assets).toEqual([]); const publishedReferences = [
...content.projects.selected.flatMap((project) => project.assetIds),
...content.projects.additional.flatMap((project) => project.assetIds),
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
technology.assetId === undefined ? [] : [technology.assetId])),
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
];
expect([...publishedReferences].sort()).toEqual(manifest.assets.map((asset) => asset.id).sort());
expect(site).toEqual({ expect(site).toEqual({
environment: 'preview', environment: 'preview',
canonicalUrl: 'https://portfolio.localhost/', canonicalUrl: 'https://portfolio.localhost/',

View File

@@ -147,6 +147,18 @@ describe('content schemas', () => {
.toThrow(/unmanifested asset/i); .toThrow(/unmanifested asset/i);
}); });
it('requires duplicate-free set equality between content references and manifest assets', () => {
const duplicateReference = cloneContent();
duplicateReference.projects.selected[1]!.assetIds = ['selected-build-one'];
expect(() => validateProductionInputs(duplicateReference, validManifest, validSite, new URL('file:///prepared/')))
.toThrow(/duplicate public asset reference/i);
const unreferencedManifestAsset = cloneContent();
unreferencedManifestAsset.projects.selected[0]!.assetIds = [];
expect(() => validateProductionInputs(unreferencedManifestAsset, validManifest, validSite, new URL('file:///prepared/')))
.toThrow(/unreferenced manifest asset/i);
});
it('rejects duplicate manifest asset IDs and unsafe generated filenames', () => { it('rejects duplicate manifest asset IDs and unsafe generated filenames', () => {
const manifest = structuredClone(validManifest) as { assets: Array<{ id: string; fileName: string }> }; const manifest = structuredClone(validManifest) as { assets: Array<{ id: string; fileName: string }> };
manifest.assets[1]!.id = manifest.assets[0]!.id; manifest.assets[1]!.id = manifest.assets[0]!.id;