diff --git a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts index 4c9cc37..e3e4c3f 100644 --- a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts +++ b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts @@ -36,6 +36,8 @@ export type CaseDocumentSnapshot = { lastChangeDateTime?: string /** Link to the CFLicense governing this framework */ licenseURI?: { title?: string; identifier?: string; uri: string } + /** OpenCASE: unauthenticated CASE API reads. Absent means sign-in is required. */ + publicAccess?: boolean } /** diff --git a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts index d15ce99..dea64bd 100644 --- a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts +++ b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts @@ -56,6 +56,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot): statusEndDate: doc.statusEndDate, lastChangeDateTime: doc.lastChangeDateTime, licenseURI: doc.licenseURI, + publicAccess: doc.publicAccess === true ? true : undefined, } const items: Framework['items'] = new Map() diff --git a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts index ab72bb0..8f10bbf 100644 --- a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts @@ -297,6 +297,9 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot } : undefined + const opencaseExt = asRecord(asRecord(doc.extensions)?.['ext:opencase']) + const publicAccess = opencaseExt?.publicAccess === true ? true : undefined + // Extract CFAssociationGroupings from CFDefinitions const defs = asRecord(pkg.CFDefinitions) const groupingsRaw = defs ? (Array.isArray(defs.CFAssociationGroupings) ? defs.CFAssociationGroupings : []) : [] @@ -340,6 +343,7 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot statusEndDate: asString(doc.statusEndDate), lastChangeDateTime: asString(doc.lastChangeDateTime), licenseURI, + publicAccess, }, items, associations, diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.test.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.test.ts index 4b1334b..63fd241 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.test.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.test.ts @@ -31,3 +31,32 @@ describe('frameworkToCfPackage — version', () => { expect(second.CFDocument.version).toBe('2.0') }) }) + +describe('frameworkToCfPackage — public access', () => { + it('writes public access into ext:opencase only when enabled', () => { + const pub = frameworkToCfPackage({ + framework: { ...makeFramework(), metadata: { title: 'Test Framework', publicAccess: true } }, + caseVersion: '1.1', + }) + const pubExt = pub.CFDocument.extensions?.['ext:opencase'] as { publicAccess?: boolean } | undefined + expect(pubExt?.publicAccess).toBe(true) + expect(pub.CFDocument.publicAccess).toBeUndefined() + + const priv = frameworkToCfPackage({ + framework: { + ...makeFramework(), + metadata: { + title: 'Test Framework', + licenseURI: { + identifier: 'c0c0c0c0-0000-4000-a000-000000000001', + uri: '/ims/case/v1p1/CFLicenses/c0c0c0c0-0000-4000-a000-000000000001', + title: 'Public Domain (CC0 1.0)', + }, + }, + }, + caseVersion: '1.1', + }) + const privExt = priv.CFDocument.extensions?.['ext:opencase'] as { publicAccess?: boolean } | undefined + expect(privExt?.publicAccess).toBeUndefined() + }) +}) diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index a1ed9d6..ecc4b7a 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -88,6 +88,7 @@ type OpencaseExtension = { colorBand?: string linkedFrameworks?: unknown[] remoteItemLinks?: unknown[] + publicAccess?: boolean } /** @@ -101,7 +102,7 @@ function mergeOpencaseExtension( const extensions = { ...base } // Only add if there's data to store - if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand || opencaseData.linkedFrameworks || opencaseData.remoteItemLinks) { + if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand || opencaseData.linkedFrameworks || opencaseData.remoteItemLinks || opencaseData.publicAccess) { const existing = (extensions[OPENCASE_EXT_KEY] as OpencaseExtension | undefined) ?? {} extensions[OPENCASE_EXT_KEY] = { ...existing, @@ -154,12 +155,13 @@ function frameworkToCfDocument( title: docTitle, identifier: fwId, }, - extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks) + extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks || meta.publicAccess === true) ? mergeOpencaseExtension(undefined, { layout, edgeType: options?.edgeType, linkedFrameworks: options?.linkedFrameworks, remoteItemLinks: options?.remoteItemLinks, + ...(meta.publicAccess === true ? { publicAccess: true } : {}), }) : undefined, } diff --git a/apps/editor/src/domain/case/types.ts b/apps/editor/src/domain/case/types.ts index 2df75a6..f9d24a6 100644 --- a/apps/editor/src/domain/case/types.ts +++ b/apps/editor/src/domain/case/types.ts @@ -68,6 +68,12 @@ export interface CFDocument { /** licensing */ licenseURI?: LinkURI + /** + * Editor-only: when true, the CASE API serves this framework without authentication. + * Persisted in `ext:opencase.publicAccess`. Absent means sign-in is required. + */ + publicAccess?: boolean + lastChangeDateTime: string /** Link back to containing package */ diff --git a/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts b/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts index fd1e2df..2aff875 100644 --- a/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts +++ b/apps/editor/src/domain/framework/hasFrameworkDataChanged.test.ts @@ -48,6 +48,13 @@ describe('hasFrameworkDataChanged', () => { expect(hasFrameworkDataChanged(a, b)).toBe(false) }) + it('ignores toggling public read access', () => { + const a = baseFramework() + const b = clone(a) + b.metadata = { ...b.metadata, publicAccess: true } + expect(hasFrameworkDataChanged(a, b)).toBe(false) + }) + it('ignores canvas-only changes nested under metadata["ext:opencase"]', () => { const a = baseFramework() const b = clone(a) diff --git a/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts b/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts index 0ad1f83..4363843 100644 --- a/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts +++ b/apps/editor/src/domain/framework/hasFrameworkDataChanged.ts @@ -49,8 +49,16 @@ function sortedNormalizedEntries(map: Map, normalize: * handle anchors), since those don't affect a mirrored framework's CASE-spec * content and shouldn't be treated as a forking change. */ +function comparableDocumentMetadata(metadata: Framework['metadata']): Framework['metadata'] { + if (!metadata) return {} + // Public read access is an OpenCASE server setting, not CASE content. + // Toggling it must not fork a mirrored framework. + const { publicAccess: _publicAccess, ...rest } = metadata + return rest +} + export function hasFrameworkDataChanged(baseline: Framework, current: Framework): boolean { - if (JSON.stringify(baseline.metadata) !== JSON.stringify(current.metadata)) return true + if (JSON.stringify(comparableDocumentMetadata(baseline.metadata)) !== JSON.stringify(comparableDocumentMetadata(current.metadata))) return true if (JSON.stringify(sortedNormalizedEntries(baseline.items, normalizeItem)) !== JSON.stringify(sortedNormalizedEntries(current.items, normalizeItem))) { diff --git a/apps/editor/src/domain/framework/model/types.ts b/apps/editor/src/domain/framework/model/types.ts index 16085dc..502f419 100644 --- a/apps/editor/src/domain/framework/model/types.ts +++ b/apps/editor/src/domain/framework/model/types.ts @@ -40,6 +40,11 @@ export type FrameworkMetadata = { lastChangeDateTime?: string /** CASE licenseURI — link to the CFLicense governing this framework */ licenseURI?: { title?: string; identifier?: string; uri: string } + /** + * When true, the CASE API serves this framework without authentication. + * Persisted as `ext:opencase.publicAccess`. Absent means sign-in is required. + */ + publicAccess?: boolean } export type ItemMetadata = Record diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index 27d15d8..aea4646 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -16,6 +16,8 @@ export type OpenCaseListExtensions = { sourcePackageURI?: string isModifiedFromSource?: boolean alignmentParticipants?: Array<{ identifier?: string; uri: string }> + /** When true, the CASE API serves this framework without authentication. */ + publicAccess?: boolean } export type AlignmentFrameworkSummary = { diff --git a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx index 18df491..da168d6 100644 --- a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx @@ -628,9 +628,32 @@ export default memo(function NodePropertiesPanel({ ) : null} + {/* ── Access (framework only) ── */} + {isFramework ? ( + +
+
+ updateDocument({ publicAccess: v === true ? true : undefined })} + /> +
+ +
+ Off by default, so readers must sign in. Turn this on and save to let anyone read the framework without signing in. +
+
+
+
+
+ ) : null} + {/* ── License (framework only) ── */} {isFramework && availableLicenses && availableLicenses.length > 0 ? ( - +