diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index f5fdda9..6238445 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -260,7 +260,7 @@ function AppInner() { useEffect(() => { if (authStatus !== 'authenticated') return let cancelled = false - api.listCfDocuments({ caseVersion: 'v1p1' }).then((docs) => { + api.listCfDocuments({ caseVersion: 'v1p1', includeArchived: false }).then((docs) => { if (cancelled) return setServerCfDocuments(docs) }).catch((err) => { diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index 7b05c4a..0c8111b 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -281,7 +281,8 @@ export class CaseApiClient { const queryParams = new URLSearchParams() if (params?.limit != null) queryParams.set('limit', String(params.limit)) if (params?.offset != null) queryParams.set('offset', String(params.offset)) - if (params?.includeArchived) queryParams.set('includeArchived', 'true') + // Server lists archived frameworks by default; only send the flag to opt out. + if (params?.includeArchived === false) queryParams.set('includeArchived', 'false') const query = queryParams.toString() const url = `/ims/case/${v}/CFDocuments${query ? `?${query}` : ''}` diff --git a/apps/editor/src/ui/home/HomeScreen.tsx b/apps/editor/src/ui/home/HomeScreen.tsx index 081e3a7..30f9ed9 100644 --- a/apps/editor/src/ui/home/HomeScreen.tsx +++ b/apps/editor/src/ui/home/HomeScreen.tsx @@ -226,7 +226,7 @@ export default function HomeScreen({ try { const docs = tenantId ? await api.listManagementCfPackages({ tenantId, caseVersion: '1.1' }) - : await api.listCfDocuments({ caseVersion: 'v1p1' }) + : await api.listCfDocuments({ caseVersion: 'v1p1', includeArchived: false }) setServerFrameworks(docs) } catch (e: unknown) { setServerFrameworks([]) @@ -264,7 +264,7 @@ export default function HomeScreen({ setArchivedLoading(true) setError(null) try { - const docs = await api.listCfDocuments({ caseVersion: 'v1p1', includeArchived: true }) + const docs = await api.listCfDocuments({ caseVersion: 'v1p1' }) // Filter to only server-level archived frameworks const archived = docs.filter((d) => d.archived === true) setArchivedFrameworks(archived) diff --git a/apps/opencase/docs/Licensing.md b/apps/opencase/docs/Licensing.md index 8fc5a7d..779def4 100644 --- a/apps/opencase/docs/Licensing.md +++ b/apps/opencase/docs/Licensing.md @@ -5,9 +5,9 @@ A framework has two independent settings: 1. **License** — the legal terms for how the framework may be used. Stored as `licenseURI` on the `CFDocument`. -2. **Access** — whether the CASE Provider API requires a sign-in to read the framework. Stored as `extensions['ext:opencase'].publicAccess`. +2. **Access** — whether the CASE Provider API requires a sign-in to read the framework's contents. Stored as `extensions['ext:opencase'].publicAccess`. -Sign-in is required unless **Make public** is turned on. Choosing an open license does not make the framework readable without authentication. +Sign-in is required unless **Make public** is turned on. Choosing an open license does not make the framework readable without authentication. Every framework's `CFDocument` is always readable without signing in, so the framework can be discovered whether or not it's public. ## Available Licenses @@ -93,7 +93,9 @@ The CASE Provider API (`/ims/case/v1p0/*` and `/ims/case/v1p1/*`) uses **optiona - If a valid bearer token is present: sets `req.isAuthenticated = true` and extracts the `tenantId` from the JWT. - If no token (or invalid token): sets `req.isAuthenticated = false` and proceeds without a `tenantId`. -Controllers then check the framework's access flag before returning data: +The `CFDocuments` endpoints (`getAllCFDocuments` and `getCFDocument`) never check access. They return every framework, public or not, to every caller, and the `total` returned by `getAllCFDocuments` counts every framework. + +Every other entity endpoint checks the framework's access flag before returning data: ``` Request arrives at GET /ims/case/v1p1/CFPackages/{id} @@ -105,7 +107,7 @@ Request arrives at GET /ims/case/v1p1/CFPackages/{id} → If authenticated or public → return the CFPackage ``` -This check happens on every entity endpoint (CFPackages, CFDocuments, CFItems, CFAssociations, CFItemAssociations, CFRubrics). +This check happens on every entity endpoint except CFDocuments (CFPackages, CFItems, CFAssociations, CFItemAssociations, CFRubrics). ### How Public/Private Is Determined @@ -115,8 +117,8 @@ At request time, `isDocumentPublic()` is true only when that flag is `true`: ``` Make public on → readable without signing in -Make public off → sign-in required -No flag set → sign-in required +Make public off → sign-in required (CFDocument still readable) +No flag set → sign-in required (CFDocument still readable) Any license → does not change the above ``` @@ -200,7 +202,7 @@ The `licenseText` field contains the human-readable license terms. For Creative | Action | Result | |---|---| -| Leave **Make public** off (default) | Framework requires authentication to read | +| Leave **Make public** off (default) | Framework contents require authentication to read; its CFDocument is listed and readable by anyone | | Turn **Make public** on and save | Framework is readable without signing in | | Turn **Make public** off and save | Sign-in is required again | | Change the license | Legal terms change; who can read the framework stays the same | diff --git a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts index ff5d9b5..26d684e 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts @@ -1077,15 +1077,6 @@ export class FileFrameworkStore { return result } - /** - * Check whether a globally-unique document ID is marked public, searching all tenants. - */ - isDocumentPublicGlobal (docId: string): boolean { - const resolved = this.resolveDocumentGlobal(docId) - if (!resolved) return false - return getOpenCasePublicAccess(resolved.metadata) - } - // Public methods for index management (used by management endpoints) removeDocumentFromIndex (tenantId: TenantId, version: CaseVersion, storageKey: string): void { const versionMap = this.documents.get(tenantId)?.get(version) diff --git a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.publicAccess.test.ts b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.publicAccess.test.ts index 62bcec0..0ff3c97 100644 --- a/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.publicAccess.test.ts +++ b/apps/opencase/src/infrastructure/persistence/file/__tests__/FileFrameworkStore.publicAccess.test.ts @@ -34,8 +34,6 @@ describe('FileFrameworkStore — public access is independent of license', () => expect(store.isDocumentPublic(tenantId, version, 'storage-licensed')).toBe(false) expect(store.isDocumentPublic(tenantId, version, 'storage-public')).toBe(true) expect(store.isDocumentPublic(tenantId, version, 'storage-private')).toBe(false) - expect(store.isDocumentPublicGlobal('doc-licensed')).toBe(false) - expect(store.isDocumentPublicGlobal('doc-public')).toBe(true) }) it('clears public access when a later save omits the flag', () => { diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFDocumentsController.ts index e69dc57..44e906a 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/CFDocumentsController.ts @@ -28,10 +28,8 @@ export class CFDocumentsControllerV1p0 { return res.status(404).json(StatusInfoFormatter.notFound('The requested CFDocument was not found.')) } - // Access control: unauthenticated requests only see public frameworks - if (!(req as any).isAuthenticated && !this.store.isDocumentPublic(resolved.tenantId, resolved.version, resolved.storageKey)) { - return res.status(401).json(StatusInfoFormatter.unauthorized('Authentication required to access this framework.')) - } + // No access check: a CFDocument is always readable. publicAccess gates the framework's + // contents (CFPackages, CFItems, ...), not its document. // On-the-fly downconversion: if stored as v1p1, load from there but serialize as v1p0 const result = await this.getCFDocument.execute({ diff --git a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts index 6bac8d8..9af5248 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p0/controllers/GetAllCFDocumentsController.ts @@ -2,26 +2,25 @@ import type { Request, Response } from 'express' import { GetAllCFDocuments } from '../../../../../application/case/endpoints/GetAllCFDocuments' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' -import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' export class GetAllCFDocumentsControllerV1p0 { - constructor ( - private readonly getAllCFDocuments: GetAllCFDocuments, - private readonly store: FileFrameworkStore - ) {} + constructor (private readonly getAllCFDocuments: GetAllCFDocuments) {} getAll = async (req: Request, res: Response) => { try { // Authenticated: scope to the authenticated tenant's documents - // Unauthenticated: list across ALL tenants (global catalog), filtered to public only + // Unauthenticated: list across ALL tenants (global catalog) + // Every framework is listed either way — publicAccess only gates the framework's + // contents (CFPackages, CFItems, ...), never its CFDocument. const tenantId = (req as any).isAuthenticated ? ((req as any).tenantId ?? undefined) : undefined const parsed = parseCaseQueryParams(req) if (!parsed.ok) return res.status(parsed.status).json(parsed.body) const { limit, offset, sort, orderBy, filter, fields } = parsed.value - // Extract includeArchived query parameter (default: false) - const includeArchived = req.query.includeArchived === 'true' + // Archiving is an OpenCASE concept, not a CASE one, so archived frameworks are listed + // by default. Callers can opt out with includeArchived=false. + const includeArchived = req.query.includeArchived !== 'false' const result = await this.getAllCFDocuments.execute({ tenantId, @@ -35,13 +34,6 @@ export class GetAllCFDocumentsControllerV1p0 { includeArchived }) - // Access control: unauthenticated requests only see public frameworks - if (!(req as any).isAuthenticated && result.CFDocuments) { - result.CFDocuments = result.CFDocuments.filter( - (doc: any) => this.store.isDocumentPublicGlobal(doc.identifier) - ) - } - const baseUrl = getBaseUrl(req) let body = absolutizeCaseUris(result, baseUrl, '1.0') if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts index c8adaca..202a1ee 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/CFDocumentsController.ts @@ -35,10 +35,8 @@ export class CFDocumentsControllerV1p1 { )) } - // Access control: unauthenticated requests only see public frameworks - if (!(req as any).isAuthenticated && !this.store.isDocumentPublic(resolved.tenantId, '1.1', resolved.storageKey)) { - return res.status(401).json(StatusInfoFormatter.unauthorized('Authentication required to access this framework.')) - } + // No access check: a CFDocument is always readable. publicAccess gates the framework's + // contents (CFPackages, CFItems, ...), not its document. const result = await this.getCFDocument.execute({ tenantId: resolved.tenantId, diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts index 81c9a13..85bde3c 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/GetAllCFDocumentsController.ts @@ -2,26 +2,25 @@ import type { Request, Response } from 'express' import { GetAllCFDocuments } from '../../../../../application/case/endpoints/GetAllCFDocuments' import { StatusInfoFormatter } from '../../../../../infrastructure/http/StatusInfoFormatter' import { absolutizeCaseUris, getBaseUrl, parseCaseQueryParams, setEtagAndHandleNotModified, stripExtensions, wantsOpenCaseExtensions } from '../utils/httpUtils' -import type { FileFrameworkStore } from '../../../../../infrastructure/persistence/file/FileFrameworkStore' export class GetAllCFDocumentsControllerV1p1 { - constructor ( - private readonly getAllCFDocuments: GetAllCFDocuments, - private readonly store: FileFrameworkStore - ) {} + constructor (private readonly getAllCFDocuments: GetAllCFDocuments) {} getAll = async (req: Request, res: Response) => { try { // Authenticated: scope to the authenticated tenant's documents - // Unauthenticated: list across ALL tenants (global catalog), filtered to public only + // Unauthenticated: list across ALL tenants (global catalog) + // Every framework is listed either way — publicAccess only gates the framework's + // contents (CFPackages, CFItems, ...), never its CFDocument. const tenantId = (req as any).isAuthenticated ? ((req as any).tenantId ?? undefined) : undefined const parsed = parseCaseQueryParams(req) if (!parsed.ok) return res.status(parsed.status).json(parsed.body) const { limit, offset, sort, orderBy, filter, fields } = parsed.value - // Extract includeArchived query parameter (default: false) - const includeArchived = req.query.includeArchived === 'true' + // Archiving is an OpenCASE concept, not a CASE one, so archived frameworks are listed + // by default. Callers can opt out with includeArchived=false. + const includeArchived = req.query.includeArchived !== 'false' const result = await this.getAllCFDocuments.execute({ tenantId, @@ -35,13 +34,6 @@ export class GetAllCFDocumentsControllerV1p1 { includeArchived }) - // Access control: unauthenticated requests only see public frameworks - if (!(req as any).isAuthenticated && result.CFDocuments) { - result.CFDocuments = result.CFDocuments.filter( - (doc: any) => this.store.isDocumentPublicGlobal(doc.identifier) - ) - } - const baseUrl = getBaseUrl(req) let body = absolutizeCaseUris(result, baseUrl) if (!wantsOpenCaseExtensions(req)) { body = stripExtensions(body) } diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFDocumentsController.test.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFDocumentsController.test.ts index dbefcb3..dff54f0 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFDocumentsController.test.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/CFDocumentsController.test.ts @@ -65,6 +65,23 @@ describe('CFDocumentsControllerV1p1', () => { expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost')) }) + it('should return a non-public CFDocument to unauthenticated requests', async () => { + const result = { + identifier: '550e8400-e29b-41d4-a716-446655440000', + uri: '/ims/case/v1p1/CFDocuments/550e8400-e29b-41d4-a716-446655440000', + title: 'Private Framework', + lastChangeDateTime: '2024-01-01T00:00:00.000Z' + } + mockStore.isDocumentPublic.mockReturnValue(false) + mockGetCFDocument.execute.mockResolvedValue(result) + ;(mockRequest as any).isAuthenticated = false + + await controller.getById(mockRequest as Request, mockResponse as Response) + + expect(responseStatus).toHaveBeenCalledWith(200) + expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost')) + }) + it('should return 404 when document is not found', async () => { mockStore.resolveDocumentGlobal.mockReturnValue(null) ;(mockRequest as any).tenantId = 'test-tenant' diff --git a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts index 4dc9d9b..b823116 100644 --- a/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts +++ b/apps/opencase/src/interfaces/http/http-public/v1p1/controllers/__tests__/GetAllCFDocumentsController.test.ts @@ -16,8 +16,7 @@ describe('GetAllCFDocumentsControllerV1p1', () => { execute: jest.fn() } as any - const mockStore = { isDocumentPublicGlobal: jest.fn() } as any - controller = new GetAllCFDocumentsControllerV1p1(mockGetAllCFDocuments, mockStore) + controller = new GetAllCFDocumentsControllerV1p1(mockGetAllCFDocuments) responseJson = jest.fn() mockResponse = { @@ -65,7 +64,7 @@ describe('GetAllCFDocumentsControllerV1p1', () => { orderBy: undefined, filter: undefined, fields: undefined, - includeArchived: false + includeArchived: true }) expect(responseStatus).toHaveBeenCalledWith(200) expect(responseJson).toHaveBeenCalledWith(absolutizeCaseUris(result as any, 'http://localhost')) @@ -102,11 +101,49 @@ describe('GetAllCFDocumentsControllerV1p1', () => { orderBy: 'asc', filter: 'test', fields: ['title', 'identifier'], - includeArchived: false + includeArchived: true }) expect(responseStatus).toHaveBeenCalledWith(200) }) + it('should exclude archived documents when includeArchived=false', async () => { + mockGetAllCFDocuments.execute.mockResolvedValue({ CFDocuments: [] }) + ;(mockRequest as any).tenantId = 'test-tenant' + ;(mockRequest as any).isAuthenticated = true + mockRequest.query = { includeArchived: 'false' } + + await controller.getAll(mockRequest as Request, mockResponse as Response) + + expect(mockGetAllCFDocuments.execute).toHaveBeenCalledWith( + expect.objectContaining({ includeArchived: false }) + ) + }) + + it('should return every document, total and all, to unauthenticated requests', async () => { + const result = { + CFDocuments: [ + { identifier: 'doc-public', uri: '/ims/case/v1p1/CFDocuments/doc-public', title: 'Public', lastChangeDateTime: '2024-01-01T00:00:00.000Z' }, + { identifier: 'doc-private', uri: '/ims/case/v1p1/CFDocuments/doc-private', title: 'Private', lastChangeDateTime: '2024-01-01T00:00:00.000Z' } + ], + total: 2, + limit: 10, + offset: 0 + } + mockGetAllCFDocuments.execute.mockResolvedValue(result) + ;(mockRequest as any).isAuthenticated = false + mockRequest.query = { limit: '10' } + + await controller.getAll(mockRequest as Request, mockResponse as Response) + + expect(mockGetAllCFDocuments.execute).toHaveBeenCalledWith( + expect.objectContaining({ tenantId: undefined, includeArchived: true }) + ) + expect(responseStatus).toHaveBeenCalledWith(200) + const body = responseJson.mock.calls[0][0] + expect(body.CFDocuments.map((d: any) => d.identifier)).toEqual(['doc-public', 'doc-private']) + expect(body.total).toBe(2) + }) + it('should strip extensions from the response when X-CASE-EDITOR header is absent', async () => { const result = { CFDocuments: [ diff --git a/apps/opencase/src/wiring/container.ts b/apps/opencase/src/wiring/container.ts index 841443a..034d444 100644 --- a/apps/opencase/src/wiring/container.ts +++ b/apps/opencase/src/wiring/container.ts @@ -288,7 +288,7 @@ export async function buildContainer(): Promise { const cfPackagesControllerV1p0 = new CFPackagesControllerV1p0(getCFPackage, store) const cfDocumentsControllerV1p0 = new CFDocumentsControllerV1p0(getCFDocument, store) - const getAllCFDocumentsControllerV1p0 = new GetAllCFDocumentsControllerV1p0(getAllCFDocuments, store) + const getAllCFDocumentsControllerV1p0 = new GetAllCFDocumentsControllerV1p0(getAllCFDocuments) const cfItemsControllerV1p0 = new CFItemsControllerV1p0(getCFItem, store) const cfAssociationsControllerV1p0 = new CFAssociationsControllerV1p0(getCFAssociation, store) const cfItemAssociationsControllerV1p0 = new CFItemAssociationsControllerV1p0(getCFItemAssociations, store) @@ -302,7 +302,7 @@ export async function buildContainer(): Promise { const cfPackagesControllerV1p1 = new CFPackagesControllerV1p1(getCFPackage, store) const cfDocumentsControllerV1p1 = new CFDocumentsControllerV1p1(getCFDocument, store) - const getAllCFDocumentsControllerV1p1 = new GetAllCFDocumentsControllerV1p1(getAllCFDocuments, store) + const getAllCFDocumentsControllerV1p1 = new GetAllCFDocumentsControllerV1p1(getAllCFDocuments) const cfItemsControllerV1p1 = new CFItemsControllerV1p1(getCFItem, store) const cfAssociationsControllerV1p1 = new CFAssociationsControllerV1p1(getCFAssociation, store) const cfItemAssociationsControllerV1p1 = new CFItemAssociationsControllerV1p1(getCFItemAssociations, store)