From 838a607ea100d85a1685a65cc43f3b02847e9fff Mon Sep 17 00:00:00 2001 From: alowpoly Date: Sun, 20 Sep 2026 07:54:54 -0300 Subject: [PATCH] fix(deploy): bind catalogues to installed R2 metadata --- astro.config.mts | 5 ++- tests/fixtures/context-package.mts | 4 ++ tools/prepare-context-availability.mts | 23 +++++++++-- tools/prepare-context-availability.test.mts | 26 +++++++++++++ tools/prepare-facilities.mts | 8 +++- tools/prepare-volume-provenance.mts | 43 +++++++++++++++++++++ 6 files changed, 102 insertions(+), 7 deletions(-) diff --git a/astro.config.mts b/astro.config.mts index 4cf4aba104..d3a48dfc0d 100644 --- a/astro.config.mts +++ b/astro.config.mts @@ -35,7 +35,10 @@ export default defineConfig({ // context package that setup:assets deliberately left missing after a 404 from R2, instead of failing the // whole build over one object. CI and local builds never set this flag and stay strict. const allowMissing = process.env.CSSEARTH_ALLOW_MISSING_ASSETS === '1'; - const { availability, failures } = await prepareContextAvailability({ strict: command === 'build' && !allowMissing }); + // An asset-origin build deliberately leaves public/scenes absent. Its tracked manifest is the local, + // content-addressed contract for previews already published to R2; all prepared package bytes stay strict. + const { availability, failures } = await prepareContextAvailability({ strict: command === 'build' && !allowMissing, + publicAssets: assetOrigin() ? 'manifest' : 'local' }); updateConfig({ vite: { define: { __CSSEARTH_CONTEXT_AVAILABILITY__: JSON.stringify(availability) } } }); if (failures.length) logger.warn(`Some 3D views are unavailable in this installation:\n${failures.join('\n')}\nPrepare their packages and restart the server to enable them.`); }, diff --git a/tests/fixtures/context-package.mts b/tests/fixtures/context-package.mts index c98535f217..3319451138 100644 --- a/tests/fixtures/context-package.mts +++ b/tests/fixtures/context-package.mts @@ -38,6 +38,10 @@ export async function writeContextPackage(root: string, id: string) { const files: [string, string | Uint8Array][] = [ [`${directory}/object.json`, JSON.stringify(descriptor)], [`${directory}/prepared/lenses.json`, bankBytes], [`${directory}/prepared/provenance.json`, JSON.stringify(provenance)], [`${directory}/prepared/presentation.json`, JSON.stringify(presentation)], + [`${directory}/runtime-assets.json`, JSON.stringify({ schema: `css${id}-runtime-assets@1`, resourceRoot: 'prepared', + assets: [{ filename: 'preview.webp', location: 'public', bytes: image.length, sha256: digest }] })], + [`${directory}/source/presentation.json`, JSON.stringify({ schema: 'cssearth-volume-presentation-source@1', objectId: id, + name: `${id} fixture`, defaultLens: 'optical', lenses: [{ id: 'optical' }] })], [`${directory}/prepared/slice.webp`, image], [`public${preview}`, image], ]; for (const [path, bytes] of files) { const file = resolve(root, path); await mkdir(dirname(file), { recursive: true }); await writeFile(file, bytes); } diff --git a/tools/prepare-context-availability.mts b/tools/prepare-context-availability.mts index e532d6e2bb..3c7ef33fa3 100644 --- a/tools/prepare-context-availability.mts +++ b/tools/prepare-context-availability.mts @@ -9,11 +9,15 @@ import type { ContextAvailability } from '../src/platform/context-availability.m import { parsePreparedVolumePresentation } from '../site/volume-presentation.mts'; import { readContextObjects } from './prepare-catalog.mts'; import { hasErrorCode } from './source-values.mts'; +import { requireRuntimeAssetManifest } from '../src/platform/runtime-asset-closure.mts'; const root = resolve(import.meta.dirname, '..'); +type PublicAssetAvailability = 'local' | 'manifest'; /** Verify complete volume packages once before serving; no source processing or downloads. */ -export async function inspectContextAvailability(projectRoot = root): Promise { +export async function inspectContextAvailability(projectRoot = root, { publicAssets = 'local' }: { + publicAssets?: PublicAssetAvailability; +} = {}): Promise { const contexts = await readContextObjects(resolve(projectRoot, 'src/objects')); const entries = await Promise.all(contexts.filter(object => object.type === 'volume-lens-bank').map(async ({ id }) => { const directory = resolve(projectRoot, 'src/objects', id); @@ -50,11 +54,20 @@ export async function inspectContextAvailability(projectRoot = root): Promise output.url === bankUrl); if (!bankPin || bankPin.sha256 !== descriptor.prepared!.sha256) throw new TypeError(`Unbound prepared bank: ${bankUrl}.`); await verify(projectRoot, bankUrl, bankPin); + const published = publicAssets === 'manifest' + ? requireRuntimeAssetManifest(id, JSON.parse((await read(directory, 'runtime-assets.json')).toString())) + : null; for (const lens of presentation.controls) for (const url of new Set([lens.thumbnailUrl, lens.texture?.url])) { if (!url?.startsWith(`/scenes/${id}/`)) throw new TypeError(`Invalid dataset preview URL: ${url}.`); const pin = outputs.find(output => output.url === url); if (!pin) throw new TypeError(`Unpinned dataset preview: ${url}.`); - await verify(resolve(projectRoot, 'public'), url.slice(1), pin); + if (published) { + const filename = url.slice(`/scenes/${id}/`.length); + const asset = published.assets.find(candidate => candidate.filename === filename && + (published.resourceRoot !== 'prepared' || candidate.location === 'public')); + if (!asset || asset.sha256 !== pin.sha256 || asset.bytes !== pin.bytes) + throw new TypeError(`Unpublished dataset preview: ${url}.`); + } else await verify(resolve(projectRoot, 'public'), url.slice(1), pin); } return [id, { available: true }] as const; } catch (error) { @@ -64,8 +77,10 @@ export async function inspectContextAvailability(projectRoot = root): Promise state.available ? [] : [`${id}: ${state.reason}`]); if (strict && failures.length) throw new Error(`Prepared context packages unavailable:\n${failures.join('\n')}`); return { availability, failures }; diff --git a/tools/prepare-context-availability.test.mts b/tools/prepare-context-availability.test.mts index 0e17dd6136..41814bf59d 100644 --- a/tools/prepare-context-availability.test.mts +++ b/tools/prepare-context-availability.test.mts @@ -6,6 +6,7 @@ import { resolve } from 'node:path'; import { inspectContextAvailability, prepareContextAvailability } from './prepare-context-availability.mts'; import { parseContextAvailability } from '../src/platform/context-availability.mts'; import { writeContextPackage } from '../tests/fixtures/context-package.mts'; +import { readPreparedVolumeProvenance } from './prepare-volume-provenance.mts'; test('a missing bank isolates one object; restoring it admits the complete package on the next startup', async t => { const root = await mkdtemp(resolve(tmpdir(), 'cssearth-availability-')); t.after(() => rm(root, { recursive: true, force: true })); @@ -53,3 +54,28 @@ test('invalid presentation and provenance cannot become available merely because assert.throws(() => parseContextAvailability({ helix: { available: 'true' } })); assert.throws(() => parseContextAvailability({ helix: { available: false } })); }); + +test('an asset-origin build verifies a missing local preview against its published manifest', async t => { + const root = await mkdtemp(resolve(tmpdir(), 'cssearth-availability-')); t.after(() => rm(root, { recursive: true, force: true })); + const f = await writeContextPackage(root, 'helix'); + await rm(resolve(root, 'public/scenes/helix/preview.webp')); + assert.equal((await inspectContextAvailability(root)).helix.available, false); + assert.deepEqual(await inspectContextAvailability(root, { publicAssets: 'manifest' }), { helix: { available: true } }); + const manifestPath = resolve(f.directory, 'runtime-assets.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.assets[0].sha256 = '0'.repeat(64); + await writeFile(manifestPath, JSON.stringify(manifest)); + assert.match((await inspectContextAvailability(root, { publicAssets: 'manifest' })).helix.reason ?? '', /Unpublished dataset preview/); +}); + +test('deploy catalogue input reads the installed prepared volume instead of regenerating it', async t => { + const root = await mkdtemp(resolve(tmpdir(), 'cssearth-prepared-volume-')); t.after(() => rm(root, { recursive: true, force: true })); + const fixture = await writeContextPackage(root, 'helix'); + const volumes = await readPreparedVolumeProvenance({ root }); + assert.equal(volumes.length, 1); + assert.equal(volumes[0]?.id, 'helix'); + assert.equal(volumes[0]?.name, 'helix fixture'); + assert.deepEqual(volumes[0]?.controls.map(control => control.id), ['optical']); + assert.deepEqual(volumes[0]?.provenance, fixture.provenance); + assert.deepEqual(volumes[0]?.outputs, []); +}); diff --git a/tools/prepare-facilities.mts b/tools/prepare-facilities.mts index 04a5f1f9d6..49cf5a66ce 100644 --- a/tools/prepare-facilities.mts +++ b/tools/prepare-facilities.mts @@ -24,7 +24,7 @@ import type { ProvenanceDocument } from '../src/platform/object-provenance.mts'; import { writePreparedSet } from './write-prepared-set.mts'; import { restoreFactsheetEvidence } from './restore-factsheet-evidence.mts'; import type { FactsheetSourceTransport } from './restore-factsheet-evidence.mts'; -import { prepareVolumeProvenance, volumeProvenanceCompilerClosure } from './prepare-volume-provenance.mts'; +import { prepareVolumeProvenance, readPreparedVolumeProvenance, volumeProvenanceCompilerClosure } from './prepare-volume-provenance.mts'; import { RUNTIME_ASSET_ORIGIN } from './source-mirror.mts'; export const explorationCompilerClosure = [ 'tools/prepare-facilities.mts', 'tools/spatial-source-citations.mts', 'packages/catalog/src/spatial.ts', 'packages/catalog/src/spatial-relations.ts', 'packages/catalog/src/clusters.ts', 'src/platform/exploration-catalog.mts', 'src/platform/exploration-contributions.mts', @@ -139,7 +139,11 @@ export async function prepareFacilities({ root = resolve(import.meta.dirname, '. inventory.push(...sourceInventory(manifest, `${base}/source/manifest.json`, sources, new Set(document.sources.map(source => source.path)))); objects.push({ id: object.id, name: object.name, route: object.route, base, controls: lenses, provenance: document }); } - const volumes = [...await prepareVolumeProvenance({ root, input, mirrorOrigin }), ...await prepareContextProvenance({ root, input })]; + // Deploys consume the exact prepared package restored from R2. Authoring preparation still rebuilds provenance + // and previews from their sources, but catalog-only publication must never invent a second package identity. + const volumes = publish === 'catalogues' + ? await readPreparedVolumeProvenance({ root, input }) + : [...await prepareVolumeProvenance({ root, input, mirrorOrigin }), ...await prepareContextProvenance({ root, input })]; for (const volume of volumes) { const document = validateObjectProvenance(volume.provenance, volume.id); const manifestPath = `${sourcePath(volume.base)}/${sourcePath(document.manifest.path)}`; diff --git a/tools/prepare-volume-provenance.mts b/tools/prepare-volume-provenance.mts index bccba9c0b2..6af97b3b2e 100644 --- a/tools/prepare-volume-provenance.mts +++ b/tools/prepare-volume-provenance.mts @@ -5,8 +5,10 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { basename, dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import sharp from 'sharp'; +import { parseObjectDescriptor } from '@cssearth/objects'; import type { Lens } from '../site/planet-shell-types.ts'; import { validateDatasetText } from '../site/dataset-content.mts'; +import { parsePreparedVolumePresentation } from '../site/volume-presentation.mts'; import { parseCapture } from '../src/platform/exploration-catalog.mts'; import { validateObjectProvenance } from '../src/platform/object-provenance.mts'; import type { ProvenanceDocument, ProvenanceSource, ProvenanceJson } from '../src/platform/object-provenance.mts'; @@ -135,6 +137,47 @@ async function hostedDatasets(root: string, base: string, objectId: string, lens for (const lensId of lensIds) if (!datasets[lensId]) throw new TypeError(`No dataset of ${hostId} shows ${objectId}/${lensId}.`); return { objectId: hostId, name: sourceText(content.displayName), route: `/${hostId}/`, datasets }; } + +/** Read the prepared package that setup:assets installed. Deploy catalogue compilation must bind to these + * R2-backed bytes; rebuilding provenance from authoring inputs can describe a different package. */ +export async function readPreparedVolumeProvenance({ root = process.cwd(), input = path => readFile(resolve(root, path)) }: { + root?: string; input?: (path: string) => Promise; +} = {}): Promise { + const results: PreparedVolumeProvenance[] = []; + const folders = await readdir(resolve(root, 'src/objects'), { withFileTypes: true }); + for (const folder of folders.filter(folder => folder.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + const id = folder.name, base = `src/objects/${id}`, sourcePresentationPath = `${base}/source/presentation.json`; + const exists = await readFile(resolve(root, sourcePresentationPath)).then(() => true, (error: unknown) => { + if (hasErrorCode(error, 'ENOENT')) return false; + throw error; + }); + if (!exists) continue; + const sourcePresentation = sourceObject(json(await input(sourcePresentationPath))); + if (sourcePresentation.schema === 'cssearth-volume-presentation-source@1') { + if (sourcePresentation.objectId !== id) throw new TypeError(`Mismatched volume presentation object: ${id}.`); + const descriptor = parseObjectDescriptor(json(await input(`${base}/object.json`))); + if (descriptor.id !== id || !descriptor.prepared || !['volume-lens-bank', 'image-layer-bank'].includes(descriptor.type)) + throw new TypeError(`Invalid prepared volume descriptor: ${id}.`); + const provenance = validateObjectProvenance(json(await input(`${base}/prepared/provenance.json`)), id); + const defaultLens = sourceId(sourcePresentation.defaultLens); + const lensIds = sourceArray(sourcePresentation.lenses, raw => sourceId(sourceObject(raw).id)); + const prepared = parsePreparedVolumePresentation(json(await input(`${base}/prepared/presentation.json`)), + { id, defaultLens, lenses: lensIds.map(lensId => ({ id: lensId })) }, provenance); + const bankUrl = `${base}/${descriptor.prepared!.url}`; + const bankPin = provenance.products.flatMap(product => product.outputs).find(output => output.url === bankUrl); + if (!bankPin || bankPin.sha256 !== descriptor.prepared!.sha256) throw new TypeError(`Unbound prepared bank: ${bankUrl}.`); + const hostedBy = await hostedDatasets(root, base, id, prepared.controls.map(control => control.id), input); + results.push({ id, name: sourceText(sourcePresentation.name), route: hostedBy?.route ?? `/sun/?focus=${id}`, base, + controls: prepared.controls, defaultLens: prepared.defaultLens, provenance, outputs: [], ...(hostedBy ? { hostedBy } : {}) }); + continue; + } + if (sourcePresentation.provenance === undefined) continue; + const preparedPresentation = sourceObject(json(await input(`${base}/prepared/presentation.json`))); + const provenance = validateObjectProvenance(json(await input(`${base}/prepared/provenance.json`)), id); + results.push({ id, name: sourceText(preparedPresentation.name), route: '/sun/', base, controls: [], defaultLens: '', provenance, outputs: [] }); + } + return results; +} interface Options { root?: string; /** Repository-relative, tracked compiler inputs only. Downloads never enter source closure. */