diff --git a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts index d1d4056..0304623 100644 --- a/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts +++ b/apps/opencase/src/application/case/endpoints/GetAllCFDocuments.ts @@ -131,18 +131,12 @@ export class GetAllCFDocuments { doc.caseVersion = caseVersion if (docMeta.frameworkType) doc.frameworkType = docMeta.frameworkType - // sourcePackageURI/isModifiedFromSource are derived from the ext:opencase extension — - // keep them nested under `extensions` (not flattened to top-level fields) so they flow - // through the same X-CASE-EDITOR gating (stripExtensions) as every other extension. - const extOpencase: Record = {} - if (docMeta.sourcePackageURI) extOpencase.sourcePackageURI = docMeta.sourcePackageURI - // isModifiedFromSource is set (true or false) on any mirrored/forked framework, - // even one imported without a known sourcePackageURI (e.g. pasted JSON) — so it - // must be surfaced even when false, since the frontend uses its presence to - // decide whether to show the Mirrored/Forked badge at all. - if (docMeta.isModifiedFromSource !== undefined) extOpencase.isModifiedFromSource = docMeta.isModifiedFromSource - if (Object.keys(extOpencase).length > 0) { - doc.extensions = { 'ext:opencase': extOpencase } + // Serve the complete, untransformed ext:opencase object verbatim (not a hand-picked + // subset) so callers that request extensions (X-CASE-EDITOR) see everything stored on + // the document — nested under `extensions` so it flows through the same X-CASE-EDITOR + // gating (stripExtensions) as every other extension. + if (docMeta.openCaseExtensions) { + doc.extensions = { 'ext:opencase': docMeta.openCaseExtensions } } } diff --git a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts index e61b360..712f7b4 100644 --- a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts +++ b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts @@ -28,7 +28,7 @@ export class ListFrameworks { subject?: string version?: string lastChangeDateTime: string - extensions?: { 'ext:opencase': { alignmentParticipants: Array<{ identifier?: string; uri: string }> } } + extensions?: { 'ext:opencase': Record } }> = [] for (const version of versions) { @@ -50,10 +50,10 @@ export class ListFrameworks { subject: doc.subject, version: doc.version, lastChangeDateTime: doc.lastChangeDateTime.toISOString(), - // alignmentParticipants is derived from the ext:opencase extension — keep it nested - // under `extensions` (not a flat top-level field) so the caller (management controller) - // can strip it the same way the public API strips `extensions` when X-CASE-EDITOR is absent. - ...(doc.alignmentParticipants ? { extensions: { 'ext:opencase': { alignmentParticipants: doc.alignmentParticipants } } } : {}) + // Serve the complete, untransformed ext:opencase object verbatim (not a hand-picked + // subset), nested under `extensions` so the caller (management controller) can strip + // it the same way the public API strips `extensions` when X-CASE-EDITOR is absent. + ...(doc.openCaseExtensions ? { extensions: { 'ext:opencase': doc.openCaseExtensions } } : {}) }) } } diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts index 354ff48..4bc37c4 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetAllCFDocuments.test.ts @@ -268,15 +268,23 @@ describe('GetAllCFDocuments', () => { expect(result.CFDocuments).toHaveLength(2) }) - it('should nest sourcePackageURI and isModifiedFromSource under extensions.ext:opencase for CASE 1.1', async () => { + it('should nest the complete, untransformed ext:opencase object under extensions for CASE 1.1', async () => { + // Includes fields (importedAt, layout, edgeType) that are NOT modeled as their own + // DocumentMetadata properties — they must still be served verbatim, unmodified. + const openCaseExtensions = { + sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc', + isModifiedFromSource: true, + importedAt: '2026-09-17T14:55:51.931Z', + layout: { x: -200, y: -80, w: 400, h: 160 }, + edgeType: 'default' + } const documents = [ { sourcedId: 'doc-1', title: 'Mirrored Document', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'file1.json', - sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc', - isModifiedFromSource: true + openCaseExtensions } ] @@ -284,14 +292,7 @@ describe('GetAllCFDocuments', () => { const result = await getAllCFDocuments.execute({ tenantId, caseVersion }) - expect(result.CFDocuments[0]).not.toHaveProperty('sourcePackageURI') - expect(result.CFDocuments[0]).not.toHaveProperty('isModifiedFromSource') - expect(result.CFDocuments[0].extensions).toEqual({ - 'ext:opencase': { - sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc', - isModifiedFromSource: true - } - }) + expect(result.CFDocuments[0].extensions).toEqual({ 'ext:opencase': openCaseExtensions }) }) it('should omit the extensions block entirely for CASE 1.0', async () => { @@ -301,8 +302,7 @@ describe('GetAllCFDocuments', () => { title: 'Mirrored Document', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'file1.json', - sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc', - isModifiedFromSource: true + openCaseExtensions: { sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc' } } ] diff --git a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts index cbb1194..23c33b2 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/ListFrameworks.test.ts @@ -212,7 +212,8 @@ describe('ListFrameworks', () => { title: 'Alignment Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-1/doc-1_v0001.json', - alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] + alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }], + openCaseExtensions: { alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] } } ] @@ -225,15 +226,21 @@ describe('ListFrameworks', () => { expect(result.frameworks[0]).not.toHaveProperty('alignmentParticipants') }) - it('should nest alignmentParticipants under extensions.ext:opencase when present', async () => { - const participants = [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }] + it('should nest the complete, untransformed ext:opencase object under extensions when present', async () => { + // Includes a field (notes) that is NOT modeled as its own DocumentMetadata property — + // it must still be served verbatim, unmodified. + const openCaseExtensions = { + alignmentParticipants: [{ identifier: 'participant-1', uri: '/ims/case/v1p1/CFDocuments/participant-1' }], + notes: 'some frontend-only annotation' + } const docs: DocumentMetadata[] = [ { sourcedId: 'doc-1', title: 'Alignment Framework', lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), currentFile: 'frameworks/doc-1/doc-1_v0001.json', - alignmentParticipants: participants + alignmentParticipants: openCaseExtensions.alignmentParticipants, + openCaseExtensions } ] @@ -243,12 +250,10 @@ describe('ListFrameworks', () => { const result = await listFrameworks.execute({ tenantId }) - expect(result.frameworks[0].extensions).toEqual({ - 'ext:opencase': { alignmentParticipants: participants } - }) + expect(result.frameworks[0].extensions).toEqual({ 'ext:opencase': openCaseExtensions }) }) - it('should omit extensions entirely when there are no alignmentParticipants', async () => { + it('should omit extensions entirely when there is no ext:opencase data', async () => { const docs: DocumentMetadata[] = [ { sourcedId: 'doc-1', diff --git a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts index be8087a..5d56dcc 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts @@ -30,6 +30,14 @@ export interface DocumentMetadata { archived?: boolean /** Participant frameworks in an alignment document (extracted from ext:opencase.alignmentParticipants) */ alignmentParticipants?: Array<{ identifier?: string; uri: string }> + /** + * The complete, untransformed `extensions['ext:opencase']` object as stored on the document, + * verbatim. Callers that request extensions (X-CASE-EDITOR) should see this whole object as-is — + * the other typed fields above (sourcePackageURI, isModifiedFromSource, alignmentParticipants) + * are a lossy subset kept separately because internal logic (fork-on-edit detection, alignment + * filtering) depends on them as structured values, not because they're the full extension data. + */ + openCaseExtensions?: Record } export interface DocumentVersionInfo { @@ -178,6 +186,7 @@ export class FileFrameworkStore { isModifiedFromSource: d.isModifiedFromSource, archived: d.archived, alignmentParticipants: d.alignmentParticipants, + openCaseExtensions: d.openCaseExtensions, }) } } catch { @@ -463,6 +472,7 @@ export class FileFrameworkStore { sourcePackageURI, isModifiedFromSource, alignmentParticipants, + openCaseExtensions: extOpencase && typeof extOpencase === 'object' ? extOpencase as Record : undefined, }) } @@ -668,6 +678,7 @@ export class FileFrameworkStore { isModifiedFromSource: meta.isModifiedFromSource, archived: meta.archived, alignmentParticipants: meta.alignmentParticipants, + openCaseExtensions: meta.openCaseExtensions, })) await fs.writeFile( diff --git a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts index a614703..7d8ee9b 100644 --- a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts +++ b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.restart.test.ts @@ -69,4 +69,41 @@ describe('FileFrameworkStore — index survives a server restart', () => { await fs.rm(baseDataDir, { recursive: true, force: true }) }) + + it('preserves the full ext:opencase extension object verbatim across a restart, including unmodeled keys', async () => { + const baseDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'case-store-restart-ext-')) + const storageKey = 'doc-1' + + const store1 = new FileFrameworkStore({ baseDataDir }) + + const extOpencase = { + sourcePackageURI: 'https://standards.example.org/ims/case/v1p1/CFPackages/abc', + isModifiedFromSource: true, + importedAt: '2026-09-17T14:55:51.931Z', + layout: { x: -200, y: -80, w: 400, h: 160 }, + edgeType: 'default' + } + const bundle = { + document: { + sourcedId: 'doc-1', + title: 'A framework', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { 'ext:opencase': extOpencase } + }, + items: [], + associations: [], + rubrics: [], + } + const { relativePath } = await store1.writeBundleFile(tenantId, version, storageKey, bundle) + await store1.updateIndexesForBundle(tenantId, version, storageKey, bundle, relativePath) + + expect(store1.getDocumentMetadata(tenantId, version, storageKey)?.openCaseExtensions).toEqual(extOpencase) + + const store2 = new FileFrameworkStore({ baseDataDir }) + await store2.loadAll() + + expect(store2.getDocumentMetadata(tenantId, version, storageKey)?.openCaseExtensions).toEqual(extOpencase) + + await fs.rm(baseDataDir, { recursive: true, force: true }) + }) })