import { ReplayTimeline, REPLAY_SCHEMA, canonicalJson, fnv1a64 } from "../worker/replay.mjs"; import { validateCheckpoint } from "./recovery.mjs"; // The package envelope is deliberately separate from the locked runtime schemas. // Format 2 adds integrity metadata; event, snapshot, checkpoint and journal // payloads remain byte-for-byte compatible with their existing v1 contracts. export const PACKAGE_SCHEMA = "workspace-package.v1"; export const ROLLBACK_SCHEMA = "workspace-rollback.v1"; export const OFFLINE_SCHEMA = "offline-cache-package.v1"; export const PACKAGE_VERSIONS = Object.freeze([1, 2]); function clone(value) { return value === undefined ? undefined : structuredClone(value); } function deterministicHash(value) { // Keep the envelope usable in a browser Worker without a host crypto module. return fnv1a64(typeof value === "string" ? value : canonicalJson(value)); } function requireString(value, name) { if (typeof value !== "string" || value.length === 0) throw new Error(`${name}_invalid`); } function requireVersion(value) { if (!PACKAGE_VERSIONS.includes(value)) throw new Error("package_version_unsupported"); } function requireSequence(value, name) { if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name}_invalid`); } function packageUnsigned(value) { const { package_hash: ignored, ...unsigned } = value; return unsigned; } function contentUnsigned(value) { return { context_id: value.context_id, committed_seq: value.committed_seq, checkpoint: value.checkpoint ?? null, journal: value.journal ?? [], timeline: value.timeline, }; } function withHashes(value) { const result = clone(value); result.content_hash = deterministicHash(contentUnsigned(result)); result.package_hash = deterministicHash(packageUnsigned(result)); return result; } function validateJournal(journal) { if (!Array.isArray(journal)) throw new Error("package_journal_invalid"); let previous = 0; for (const record of journal) { if (!record || typeof record !== "object" || Array.isArray(record)) throw new Error("package_journal_invalid"); if (record.commit_seq !== undefined) { requireSequence(record.commit_seq, "journal_commit_seq"); if (record.commit_seq < previous) throw new Error("package_journal_order_invalid"); previous = record.commit_seq; } } } function normalizedJournal(journal) { if (!Array.isArray(journal) || journal.some((record) => !record || typeof record !== "object" || Array.isArray(record))) throw new Error("package_journal_invalid"); const normalized = clone(journal).sort((left, right) => (left.commit_seq ?? 0) - (right.commit_seq ?? 0) || String(left.kind ?? "").localeCompare(String(right.kind ?? "")) || String(left.operation_id ?? "").localeCompare(String(right.operation_id ?? ""))); validateJournal(normalized); return normalized; } function validateTimeline(timeline) { if (!timeline || timeline.schema !== REPLAY_SCHEMA) throw new Error("package_timeline_schema"); return new ReplayTimeline(timeline); } function validatePackageShape(value, { contextId, expectedVersion } = {}) { if (!value || value.schema !== PACKAGE_SCHEMA) throw new Error("package_schema_unknown"); requireVersion(value.format_version); if (expectedVersion !== undefined && value.format_version !== expectedVersion) throw new Error("package_version_mismatch"); requireString(value.context_id, "package_context"); if (contextId !== undefined && value.context_id !== contextId) throw new Error("package_context_mismatch"); requireSequence(value.committed_seq, "package_committed_seq"); validateTimeline(value.timeline); validateJournal(value.journal ?? []); const committedJournalSequences = (value.journal ?? []).filter((record) => record.kind === "commit").map((record) => record.commit_seq); if (committedJournalSequences.length > 0 && !committedJournalSequences.includes(value.committed_seq)) throw new Error("package_committed_seq_unverified"); if (value.checkpoint !== null && value.checkpoint !== undefined) { validateCheckpoint(value.checkpoint, { contextId: value.context_id }); if (value.checkpoint.event_cursor > value.timeline.events.cursor) throw new Error("package_checkpoint_ahead"); } if (typeof value.content_hash !== "string" || value.content_hash !== deterministicHash(contentUnsigned(value))) throw new Error("package_content_hash_mismatch"); if (typeof value.package_hash !== "string" || value.package_hash !== deterministicHash(packageUnsigned(value))) throw new Error("package_hash_mismatch"); if (value.format_version === 2) { if (!value.integrity || value.integrity.timeline_hash !== deterministicHash(value.timeline) || value.integrity.journal_hash !== deterministicHash(value.journal ?? []) || value.integrity.checkpoint_hash !== (value.checkpoint ? deterministicHash(value.checkpoint) : null)) throw new Error("package_integrity_metadata_mismatch"); if (!value.migration || value.migration.from_version !== 1 || value.migration.to_version !== 2 || value.migration.lossless !== true || typeof value.migration.source_package_hash !== "string") throw new Error("package_migration_metadata_invalid"); } return clone(value); } export function createWorkspacePackage({ contextId, timeline, checkpoint = null, journal = [], committedSeq = undefined } = {}) { requireString(contextId, "package_context"); const normalizedTimeline = timeline instanceof ReplayTimeline ? timeline.export() : clone(timeline); const imported = validateTimeline(normalizedTimeline); if (checkpoint !== null) validateCheckpoint(checkpoint, { contextId }); const normalized = normalizedJournal(journal); const committed_seq = committedSeq === undefined ? imported.events.cursor : committedSeq; requireSequence(committed_seq, "package_committed_seq"); if (committed_seq > imported.events.cursor) throw new Error("package_committed_seq_ahead"); return withHashes({ schema: PACKAGE_SCHEMA, format_version: 1, context_id: contextId, committed_seq, checkpoint: clone(checkpoint), timeline: normalizedTimeline, journal: normalized, }); } export function verifyWorkspacePackage(value, options = {}) { return validatePackageShape(value, options); } export function migrateWorkspacePackage(value, targetVersion) { const source = validatePackageShape(value); requireVersion(targetVersion); if (source.format_version === targetVersion) return clone(source); if (source.format_version === 1 && targetVersion === 2) { return withHashes({ ...source, format_version: 2, migration: { from_version: 1, to_version: 2, source_package_hash: source.package_hash, lossless: true, }, integrity: { timeline_hash: deterministicHash(source.timeline), journal_hash: deterministicHash(source.journal), checkpoint_hash: source.checkpoint ? deterministicHash(source.checkpoint) : null, }, }); } if (source.format_version === 2 && targetVersion === 1) { if (!source.migration || source.migration.from_version !== 1 || source.migration.to_version !== 2 || source.migration.lossless !== true) throw new Error("package_downgrade_not_proven"); const downgraded = withHashes({ schema: PACKAGE_SCHEMA, format_version: 1, context_id: source.context_id, committed_seq: source.committed_seq, checkpoint: clone(source.checkpoint ?? null), timeline: clone(source.timeline), journal: clone(source.journal ?? []), }); if (source.migration.source_package_hash !== downgraded.package_hash) throw new Error("package_downgrade_source_mismatch"); return downgraded; } throw new Error("package_migration_unsupported"); } export function createRollbackPackage({ current, committed } = {}) { const currentPackage = validatePackageShape(current); const committedPackage = validatePackageShape(committed, { contextId: currentPackage.context_id }); if (committedPackage.committed_seq > currentPackage.committed_seq) throw new Error("rollback_target_not_committed"); const rollback = { schema: ROLLBACK_SCHEMA, format_version: 1, context_id: currentPackage.context_id, current_package_hash: currentPackage.package_hash, target_package_hash: committedPackage.package_hash, nearest_committed_seq: committedPackage.committed_seq, target: committedPackage, }; return { ...rollback, rollback_hash: deterministicHash(rollback) }; } export function applyRollbackPackage(rollback, { currentPackageHash, failureInjection = null } = {}) { if (!rollback || rollback.schema !== ROLLBACK_SCHEMA || rollback.format_version !== 1) throw new Error("rollback_schema_unknown"); const { rollback_hash: ignored, ...unsigned } = rollback; if (typeof rollback.rollback_hash !== "string" || rollback.rollback_hash !== deterministicHash(unsigned)) throw new Error("rollback_hash_mismatch"); if (typeof currentPackageHash !== "string" || currentPackageHash !== rollback.current_package_hash) throw new Error("rollback_current_mismatch"); if (failureInjection === "before_commit") throw new Error("rollback_injected_failure"); const target = validatePackageShape(rollback.target, { contextId: rollback.context_id }); if (target.package_hash !== rollback.target_package_hash) throw new Error("rollback_target_hash_mismatch"); return clone(target); } function normalizeResources(resources) { if (!Array.isArray(resources) || resources.length === 0) throw new Error("offline_resources_empty"); const seen = new Set(); return resources.map((resource) => { requireString(resource?.path, "offline_resource_path"); if (seen.has(resource.path)) throw new Error("offline_resource_duplicate"); seen.add(resource.path); if (typeof resource.data !== "string") throw new Error("offline_resource_data_invalid"); return { path: resource.path, bytes: new TextEncoder().encode(resource.data).byteLength, data_hash: deterministicHash(resource.data) }; }).sort((left, right) => left.path.localeCompare(right.path)); } export function createOfflinePackage({ workspacePackage, resources } = {}) { const workspace = validatePackageShape(workspacePackage); const normalized = normalizeResources(resources); const bundle = { schema: OFFLINE_SCHEMA, format_version: 1, context_id: workspace.context_id, workspace_package_hash: workspace.package_hash, resources: normalized, required_paths: normalized.map((resource) => resource.path), }; return { ...bundle, bundle_hash: deterministicHash(bundle) }; } export function verifyOfflinePackage(bundle, { contextId, workspacePackage = null, available = [], online = false } = {}) { if (!bundle || bundle.schema !== OFFLINE_SCHEMA || bundle.format_version !== 1) throw new Error("offline_schema_unknown"); requireString(bundle.context_id, "offline_context"); if (contextId !== undefined && bundle.context_id !== contextId) throw new Error("offline_context_mismatch"); if (workspacePackage !== null && verifyWorkspacePackage(workspacePackage, { contextId: bundle.context_id }).package_hash !== bundle.workspace_package_hash) throw new Error("offline_workspace_hash_mismatch"); if (!Array.isArray(bundle.resources) || !Array.isArray(bundle.required_paths) || canonicalJson(bundle.required_paths) !== canonicalJson(bundle.resources.map((resource) => resource.path))) throw new Error("offline_manifest_invalid"); if (typeof bundle.bundle_hash !== "string" || bundle.bundle_hash !== deterministicHash({ schema: bundle.schema, format_version: bundle.format_version, context_id: bundle.context_id, workspace_package_hash: bundle.workspace_package_hash, resources: bundle.resources, required_paths: bundle.required_paths })) throw new Error("offline_bundle_hash_mismatch"); if (!Array.isArray(available)) throw new Error("offline_available_invalid"); const availableMap = new Map(available.map((entry) => { if (typeof entry === "string") return [entry, null]; if (entry && typeof entry.path === "string") return [entry.path, entry.data_hash ?? (typeof entry.data === "string" ? deterministicHash(entry.data) : null)]; throw new Error("offline_available_invalid"); })); const missing = bundle.resources.filter((resource) => !availableMap.has(resource.path)).map((resource) => resource.path); const corrupted = bundle.resources.filter((resource) => availableMap.has(resource.path) && availableMap.get(resource.path) !== null && availableMap.get(resource.path) !== resource.data_hash).map((resource) => resource.path); if ((missing.length > 0 || corrupted.length > 0) && !online) throw new Error(`offline_resource_invalid:${[...missing, ...corrupted].join(",")}`); return { status: missing.length === 0 && corrupted.length === 0 ? "ready" : "network_required", missing, corrupted, offline: !online }; } export { canonicalJson, deterministicHash, fnv1a64 };