Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 : []) : []
Expand Down Expand Up @@ -340,6 +343,7 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot
statusEndDate: asString(doc.statusEndDate),
lastChangeDateTime: asString(doc.lastChangeDateTime),
licenseURI,
publicAccess,
},
items,
associations,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type OpencaseExtension = {
colorBand?: string
linkedFrameworks?: unknown[]
remoteItemLinks?: unknown[]
publicAccess?: boolean
}

/**
Expand All @@ -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,
Expand Down Expand Up @@ -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,
}
Expand Down
6 changes: 6 additions & 0 deletions apps/editor/src/domain/case/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion apps/editor/src/domain/framework/hasFrameworkDataChanged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,16 @@ function sortedNormalizedEntries<K extends string, V>(map: Map<K, V>, 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))) {
Expand Down
5 changes: 5 additions & 0 deletions apps/editor/src/domain/framework/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
Expand Down
2 changes: 2 additions & 0 deletions apps/editor/src/infrastructure/caseApi/CaseApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
25 changes: 24 additions & 1 deletion apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -628,9 +628,32 @@ export default memo(function NodePropertiesPanel({
</SidebarSection>
) : null}

{/* ── Access (framework only) ── */}
{isFramework ? (
<SidebarSection title="Access" subtitle="Who can read this framework from the API." accentColor={accentColor} defaultOpen>
<div className="rounded-xl border border-black/10 bg-white p-3">
<div className="flex items-start gap-3">
<Checkbox
id="node-fw-public"
checked={cfDocument?.publicAccess === true}
onCheckedChange={(v: CheckedState) => updateDocument({ publicAccess: v === true ? true : undefined })}
/>
<div className="grid gap-1">
<Label htmlFor="node-fw-public" className="text-sm font-medium text-slate-900">
Make public
</Label>
<div className={HINT_CLS}>
Off by default, so readers must sign in. Turn this on and save to let anyone read the framework without signing in.
</div>
</div>
</div>
</div>
</SidebarSection>
) : null}

{/* ── License (framework only) ── */}
{isFramework && availableLicenses && availableLicenses.length > 0 ? (
<SidebarSection title="License" subtitle="Choose who can use this framework and how." accentColor={accentColor} defaultOpen>
<SidebarSection title="License" subtitle="Legal terms for how this framework may be used." accentColor={accentColor} defaultOpen>
<div className="space-y-3">
<select
id="node-license"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr
statusEndDate: doc?.statusEndDate,
lastChangeDateTime: doc?.lastChangeDateTime,
licenseURI: doc?.licenseURI,
publicAccess: doc?.publicAccess === true ? true : undefined,
}

const items: Framework['items'] = new Map()
Expand Down
1 change: 1 addition & 0 deletions apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ function mapDomainFrameworkToCfDocument(framework: Framework): CFDocument {
lastChangeDateTime: meta.lastChangeDateTime ?? nowIso(),
CFPackageURI: { uri: `urn:case:package:${id}` },
licenseURI: meta.licenseURI,
publicAccess: meta.publicAccess === true ? true : undefined,
}
}

Expand Down
3 changes: 3 additions & 0 deletions apps/editor/src/ui/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ export default function HomeScreen({
sourcePackageURI={doc.extensions?.['ext:opencase']?.sourcePackageURI}
isModifiedFromSource={doc.extensions?.['ext:opencase']?.isModifiedFromSource}
readOnly={doc.readOnly === true}
publicAccess={doc.extensions?.['ext:opencase']?.publicAccess === true}
rightHint={isArchiving ? 'Archiving' : hint}
lastChanged={doc.lastChangeDateTime}
onClick={() => openRemote(doc.identifier)}
Expand Down Expand Up @@ -904,6 +905,7 @@ export default function HomeScreen({
sourcePackageURI={doc.extensions?.['ext:opencase']?.sourcePackageURI}
isModifiedFromSource={doc.extensions?.['ext:opencase']?.isModifiedFromSource}
readOnly
publicAccess={doc.extensions?.['ext:opencase']?.publicAccess === true}
rightHint={isArchiving ? 'Archiving' : hint}
lastChanged={doc.lastChangeDateTime}
onClick={() => openRemote(doc.identifier)}
Expand Down Expand Up @@ -956,6 +958,7 @@ export default function HomeScreen({
sourcePackageURI={doc.extensions?.['ext:opencase']?.sourcePackageURI}
isModifiedFromSource={doc.extensions?.['ext:opencase']?.isModifiedFromSource}
readOnly={doc.readOnly === true}
publicAccess={doc.extensions?.['ext:opencase']?.publicAccess === true}
rightHint={isDeleting ? 'Deleting' : (isRestoring ? 'Restoring' : undefined)}
lastChanged={doc.lastChangeDateTime}
onDelete={tenantId ? () => handleHardDeleteRequest(doc.identifier, title) : undefined}
Expand Down
1 change: 1 addition & 0 deletions apps/editor/src/ui/home/frameworkStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function createCfDocumentFromFramework(framework: Framework): CFDocument {
lastChangeDateTime: meta.lastChangeDateTime ?? new Date().toISOString(),
CFPackageURI: { uri: `urn:case:package:${id}` },
licenseURI: meta.licenseURI,
publicAccess: meta.publicAccess === true ? true : undefined,
}
}

Expand Down
8 changes: 8 additions & 0 deletions apps/editor/src/ui/shared/components/FrameworkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ type Props = {
isModifiedFromSource?: boolean
/** Read-only cached remote framework (CGE cache) */
readOnly?: boolean
/** CASE API readable without signing in */
publicAccess?: boolean
}

export function FrameworkCard({
Expand All @@ -95,6 +97,7 @@ export function FrameworkCard({
sourcePackageURI,
isModifiedFromSource,
readOnly,
publicAccess,
}: Readonly<Props>) {
const title = cfDocument.title ?? 'Untitled framework'
const frameworkType = (cfDocument as { frameworkType?: string }).frameworkType
Expand Down Expand Up @@ -218,6 +221,11 @@ export function FrameworkCard({
Read-only
</span>
) : null}
{publicAccess ? (
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-800" title="Anyone can read this framework without signing in">
Public
</span>
) : null}
{isModifiedFromSource !== undefined ? (
<span
className={cn(
Expand Down
2 changes: 1 addition & 1 deletion apps/opencase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ Framework data is stored as versioned files rather than in a traditional databas
| [API Endpoint Reference](FRAMEWORK_MANAGEMENT_GUIDE.md) | Complete endpoint reference for building integrations |
| [Data Model](docs/DataModel.md) | Official CASE v1.1 data model specification |
| [REST Bindings](docs/RESTBindings.md) | Official CASE v1.1 REST binding specification |
| [Licensing](docs/Licensing.md) | Framework-level licensing and public access controls |
| [Licensing](docs/Licensing.md) | Framework licenses, and a separate public-read switch |
Loading
Loading