Skip to content
Merged
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 { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage'
import type { LayoutState } from '@/ui/editor/reactflow/mapping'
import type { CaseVersion } from '@/application/framework/mappers/case/CasePackageSnapshot'
Expand Down Expand Up @@ -201,6 +201,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 @@ -371,6 +384,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 @@ -459,7 +475,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
64 changes: 44 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,12 @@ 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[]
}

/**
Expand All @@ -103,7 +101,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) {
const existing = (extensions[OPENCASE_EXT_KEY] as OpencaseExtension | undefined) ?? {}
extensions[OPENCASE_EXT_KEY] = {
...existing,
Expand All @@ -121,7 +119,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 +154,13 @@ function frameworkToCfDocument(
title: docTitle,
identifier: fwId,
},
extensions: (layout || options?.edgeType)
? mergeOpencaseExtension(undefined, { layout, edgeType: options?.edgeType })
extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks)
? mergeOpencaseExtension(undefined, {
layout,
edgeType: options?.edgeType,
linkedFrameworks: options?.linkedFrameworks,
remoteItemLinks: options?.remoteItemLinks,
})
: undefined,
}

Expand Down Expand Up @@ -264,6 +267,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 +276,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 +352,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
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)
})
})
71 changes: 71 additions & 0 deletions apps/editor/src/infrastructure/auth/tokenScopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Decode a JWT payload without verifying signature (UI gating only).
* Server always enforces scopes.
*/
export function decodeJwtPayload (accessToken: string | null | undefined): Record<string, unknown> | null {
if (!accessToken) return null
const parts = accessToken.split('.')
if (parts.length < 2) return null
try {
const json = base64UrlDecode(parts[1])
return JSON.parse(json) as Record<string, unknown>
} catch {
return null
}
}

function base64UrlDecode (input: string): string {
let base64 = input.replaceAll('-', '+').replaceAll('_', '/')
const pad = base64.length % 4
if (pad === 2) base64 += '=='
else if (pad === 3) base64 += '='
else if (pad === 1) base64 += '===' // invalid length; atob may still throw
return atob(base64)
}

function collectScopes (payload: Record<string, unknown>): Set<string> {
const scopes = new Set<string>()
const raw = payload.scope
if (typeof raw === 'string') {
for (const s of raw.split(' ').filter(Boolean)) scopes.add(s)
} else if (Array.isArray(raw)) {
for (const s of raw) if (typeof s === 'string') scopes.add(s)
}

const realmAccess = payload.realm_access as { roles?: unknown } | undefined
if (Array.isArray(realmAccess?.roles)) {
for (const r of realmAccess.roles) if (typeof r === 'string') scopes.add(r)
}

const resourceAccess = payload.resource_access
if (resourceAccess && typeof resourceAccess === 'object') {
for (const client of Object.values(resourceAccess as Record<string, { roles?: unknown }>)) {
const roles = client?.roles
if (Array.isArray(roles)) {
for (const r of roles) if (typeof r === 'string') scopes.add(r)
}
}
}

// Membership labels + case.* hierarchy (matches OpenCASE middleware)
if (scopes.has('admin') || scopes.has('case.owner')) {
scopes.add('case.owner')
scopes.add('case.write')
scopes.add('case.read')
} else if (scopes.has('author') || scopes.has('case.write')) {
scopes.add('case.write')
scopes.add('case.read')
} else if (scopes.has('viewer') || scopes.has('case.read')) {
scopes.add('case.read')
}

return scopes
}

/** True when the access token can manage tenant members/keys (owner, membership admin, or system case.admin). */
export function tokenHasCaseOwner (accessToken: string | null | undefined): boolean {
const payload = decodeJwtPayload(accessToken)
if (!payload) return false
const scopes = collectScopes(payload)
return scopes.has('case.owner') || scopes.has('admin') || scopes.has('case.admin')
}
Loading
Loading