Skip to content
Open
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
26 changes: 24 additions & 2 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getAppConfig } from '@/app/config'
import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/CaseApiClient'
import { createFetchHttpClient } from '@/infrastructure/caseApi/http'
import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader'
import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage } from '@/ui/editor/reactflow/mapping'
import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage, extractRemoteFrameworkDataFromCfPackage, normalizeLinkedFrameworkColors } from '@/ui/editor/reactflow/mapping'
import type { LayoutState } from '@/ui/editor/reactflow/mapping'
import type { CaseVersion } from '@/application/framework/mappers/case/CasePackageSnapshot'
import type { CFAssociationGrouping, CFItemType, CFLicense, CFSubject, CFConcept } from '@/domain/case/types'
Expand Down Expand Up @@ -204,6 +204,19 @@ function AppInner() {
setRoute('login')
}, [authStatus, route])

// SSO: ensure default tenant membership when org_id claim matches (idempotent).
const ensureSelfAttempted = useRef<string | null>(null)
useEffect(() => {
if (authStatus !== 'authenticated' || !tenantId) return
const key = tenantId
if (ensureSelfAttempted.current === key) return
ensureSelfAttempted.current = key
void api.ensureSelfMembership({ tenantId }).catch((err: unknown) => {
// Expected when org_id claim is absent (non-SSO / local users).
console.debug('[App] ensure-self skipped or failed:', err)
})
}, [authStatus, tenantId, api])

// Fetch the full definitions catalogue from the management endpoint once authenticated.
useEffect(() => {
if (authStatus !== 'authenticated' || !tenantId) return
Expand Down Expand Up @@ -381,6 +394,9 @@ function AppInner() {

// Create a HomeFramework entry from the domain Framework
const fw = createHomeFrameworkFromDomain(framework, mirrorStatus)
if (pkg.CFDocument?.extensions) {
fw.cfDocument = { ...fw.cfDocument, extensions: pkg.CFDocument.extensions }
}

// Store the extracted layout
if (layout) {
Expand Down Expand Up @@ -473,7 +489,13 @@ function AppInner() {

// Get the stored layout for this framework (from CASE extensions)
const layout = frameworkLayouts[activeFramework.id]
const graph = toReactFlowGraph({ framework: activeFramework.framework, layout })
const remoteEditorData = extractRemoteFrameworkDataFromCfPackage({
CFDocument: activeFramework.cfDocument,
CFItems: [],
CFAssociations: [],
})
remoteEditorData.linkedFrameworks = normalizeLinkedFrameworkColors(remoteEditorData.linkedFrameworks)
const graph = toReactFlowGraph({ framework: activeFramework.framework, layout, remoteEditorData })

// If no saved layout, detect topology and apply appropriate layout
if (!layout) {
Expand Down
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()
})
})
66 changes: 46 additions & 20 deletions apps/editor/src/application/framework/mappers/case/toCasePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,13 @@ function makePackageUri(uuid: string): string {
type OpencaseExtension = {
layout?: NodeLayout
notes?: string
/** Persisted handle ID on the origin (from) node — preserves user-defined edge anchors */
originHandle?: string
/** Persisted handle ID on the destination (to) node — preserves user-defined edge anchors */
destinationHandle?: string
/** Edge rendering style for this framework (e.g. 'default', 'smoothstep', 'straight') */
edgeType?: string
/** Visual color band hex color for item nodes */
colorBand?: string
linkedFrameworks?: unknown[]
remoteItemLinks?: unknown[]
publicAccess?: boolean
}

/**
Expand All @@ -103,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) {
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 All @@ -121,7 +120,7 @@ function frameworkToCfDocument(
framework: Framework,
caseVersion: CaseVersion,
layout?: NodeLayout,
options?: { edgeType?: string }
options?: { edgeType?: string; linkedFrameworks?: unknown[]; remoteItemLinks?: unknown[] }
): CFDocument {
const meta = framework.metadata
const fwId = String(framework.id)
Expand Down Expand Up @@ -156,8 +155,14 @@ function frameworkToCfDocument(
title: docTitle,
identifier: fwId,
},
extensions: (layout || options?.edgeType)
? mergeOpencaseExtension(undefined, { layout, edgeType: options?.edgeType })
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 Expand Up @@ -264,6 +269,7 @@ function associationToCfAssociation(
}

const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined
const remoteExt = (existingExtensions?.[OPENCASE_EXT_KEY] as { remoteLink?: boolean; remoteItemUri?: string; remoteItemIdentifier?: string; remoteLabel?: string; localItemUri?: string } | undefined)

// Persist user-defined edge handle positions in ext:opencase
const originHandle = s('originHandle')
Expand All @@ -272,21 +278,35 @@ function associationToCfAssociation(
? mergeOpencaseExtension(existingExtensions, { originHandle, destinationHandle })
: existingExtensions

const isRemoteLink = remoteExt?.remoteLink === true

const cfAssociation: CFAssociation & { sourcedId: string } = {
identifier: assocId,
sourcedId: assocId, // OpenCASE requires sourcedId
uri: s('caseUri') ?? `urn:case:association:${assocId}`,
associationType: assoc.associationType,
originNodeURI: {
identifier: fromId,
uri: s('originUri') ?? `urn:case:item:${fromId}`,
title: fromTitle,
},
destinationNodeURI: {
identifier: toId,
uri: s('destinationUri') ?? `urn:case:item:${toId}`,
title: toTitle,
},
originNodeURI: isRemoteLink
? {
identifier: fromId,
uri: remoteExt?.localItemUri ?? s('originUri') ?? `urn:case:item:${fromId}`,
title: fromTitle,
}
: {
identifier: fromId,
uri: s('originUri') ?? `urn:case:item:${fromId}`,
title: fromTitle,
},
destinationNodeURI: isRemoteLink
? {
identifier: remoteExt?.remoteItemIdentifier ?? toId,
uri: remoteExt?.remoteItemUri ?? s('destinationUri') ?? `urn:case:item:${toId}`,
title: remoteExt?.remoteLabel ?? toTitle,
}
: {
identifier: toId,
uri: s('destinationUri') ?? `urn:case:item:${toId}`,
title: toTitle,
},
sequenceNumber: n('sequenceNumber'),
CFAssociationGroupingURI: s('CFAssociationGroupingIdentifier')
? {
Expand Down Expand Up @@ -334,13 +354,19 @@ export function frameworkToCfPackage(params: {
cfAssociationGroupings?: CFAssociationGrouping[]
/** CFLicense definitions to include in CFDefinitions (from editor state) */
cfLicenses?: CFLicense[]
/** Remote framework editor data from canvas */
remoteEditorData?: { linkedFrameworks: unknown[]; remoteItemLinks: unknown[] }
}): CFPackage {
const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses } = params
const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses, remoteEditorData } = params
const fwId = String(framework.id)

// Build CFDocument
const documentLayout = layout?.byNodeId?.[fwId]
const document = frameworkToCfDocument(framework, caseVersion, documentLayout, { edgeType })
const document = frameworkToCfDocument(framework, caseVersion, documentLayout, {
edgeType,
linkedFrameworks: remoteEditorData?.linkedFrameworks,
remoteItemLinks: remoteEditorData?.remoteItemLinks,
})

// Build CFItems
const itemIds = Array.from(framework.items.keys()).map(String)
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
36 changes: 36 additions & 0 deletions apps/editor/src/infrastructure/auth/tokenScopes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { tokenHasCaseOwner } from './tokenScopes'

function makeToken (payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.sig`
}

describe('tokenScopes', () => {
it('detects case.owner from scope claim', () => {
expect(tokenHasCaseOwner(makeToken({ scope: 'case.read case.owner' }))).toBe(true)
})

it('detects admin membership role as tenant admin', () => {
expect(tokenHasCaseOwner(makeToken({
resource_access: { 'tenant-demo': { roles: ['admin'] } },
}))).toBe(true)
})

it('detects case.owner from resource_access roles', () => {
expect(tokenHasCaseOwner(makeToken({
resource_access: { 'tenant-demo': { roles: ['case.owner'] } },
}))).toBe(true)
})

it('returns false for author-only tokens', () => {
expect(tokenHasCaseOwner(makeToken({ scope: 'case.read case.write author' }))).toBe(false)
})

it('detects case.admin (system admin) as tenant admin for UI', () => {
expect(tokenHasCaseOwner(makeToken({
resource_access: { 'tenant-system': { roles: ['case.admin'] } },
}))).toBe(true)
})
})
Loading
Loading