From 39edeae22305619855ac9cb3b02c0ea4e80b9c0d Mon Sep 17 00:00:00 2001 From: Jonatan Jacobsson Date: Wed, 26 Aug 2026 00:06:27 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(bcf-api,viewer):=20BCF=20server=20conn?= =?UTF-8?q?ector=20=E2=80=94=20pull=20topics=20from=20BCF=20API=20(OpenCDE?= =?UTF-8?q?)=20servers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package @ifc-lite/bcf-api: typed BCF API 2.1 REST client (projects, extensions, topics with OData paging, comments, viewpoints, component subresources, snapshots), OAuth2 password / refresh / client-credentials / authorization-code grants plus dynamic client registration, and fetchProjectAsBCF assembling a whole server project into the @ifc-lite/bcf in-memory model with per-item warning degradation (auth failures stay fatal). Wire DTOs tolerate explicit nulls; mapping normalizes them to undefined. BCF API's wire default default_visibility=false is honored (BCF-XML reads a missing attribute as true — the two differ). Viewer: a cloud button in the BCF panel opens a connect dialog — pick a known server (verified live: 8 Aconex regions, BIMcollab, BIMData.io, BIM Track/Newforma Konekt, Catenda, Dalux Field, OpenProject, StreamBIM) or a custom URL, sign in via browser OAuth popup (PKCE; dynamic client registration when the server offers it), email+password, pasted access token, or client credentials, then load a project's topics/viewpoints/ snapshots into the existing bcfSlice so the whole BCF UI works unchanged. Tokens auto-refresh (proactive expiry + one 401 retry); sign-out survives in-flight refreshes; the discovered token endpoint must pass the same TLS rule as the server URL; loading over unsaved local topics requires an explicit second click. Co-Authored-By: Claude Fable 5 --- .changeset/bcf-api-client.md | 5 + apps/viewer/package.json | 16 +- apps/viewer/public/oauth/bcf/callback.html | 98 ++++ .../viewer/src/components/viewer/BCFPanel.tsx | 15 + .../viewer/bcf/BCFServerConnectForm.tsx | 318 ++++++++++++ .../viewer/bcf/BCFServerDialog.test.tsx | 282 +++++++++++ .../components/viewer/bcf/BCFServerDialog.tsx | 273 +++++++++++ .../viewer/bcf/bcf-server-presets.test.ts | 44 ++ .../viewer/bcf/bcf-server-presets.ts | 173 +++++++ apps/viewer/src/services/bcf-server.test.ts | 409 ++++++++++++++++ apps/viewer/src/services/bcf-server.ts | 458 ++++++++++++++++++ apps/viewer/tsconfig.json | 2 + apps/viewer/vite-plugins/oauth-callback.ts | 2 + apps/viewer/vite.config.ts | 3 + docs/api/typescript.md | 3 +- docs/guide/bcf.md | 44 +- packages/bcf-api/README.md | 68 +++ packages/bcf-api/package.json | 55 +++ packages/bcf-api/src/auth.test.ts | 230 +++++++++ packages/bcf-api/src/auth.ts | 226 +++++++++ packages/bcf-api/src/client.test.ts | 153 ++++++ packages/bcf-api/src/client.ts | 304 ++++++++++++ packages/bcf-api/src/errors.ts | 56 +++ packages/bcf-api/src/index.ts | 63 +++ packages/bcf-api/src/mapping.test.ts | 307 ++++++++++++ packages/bcf-api/src/mapping.ts | 255 ++++++++++ packages/bcf-api/src/sync.test.ts | 245 ++++++++++ packages/bcf-api/src/sync.ts | 276 +++++++++++ packages/bcf-api/src/types.ts | 233 +++++++++ packages/bcf-api/tsconfig.json | 21 + packages/bcf-api/vitest.config.ts | 21 + pnpm-lock.yaml | 19 + scripts/api-surface.json | 49 ++ vercel.json | 4 + 34 files changed, 4717 insertions(+), 13 deletions(-) create mode 100644 .changeset/bcf-api-client.md create mode 100644 apps/viewer/public/oauth/bcf/callback.html create mode 100644 apps/viewer/src/components/viewer/bcf/BCFServerConnectForm.tsx create mode 100644 apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx create mode 100644 apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx create mode 100644 apps/viewer/src/components/viewer/bcf/bcf-server-presets.test.ts create mode 100644 apps/viewer/src/components/viewer/bcf/bcf-server-presets.ts create mode 100644 apps/viewer/src/services/bcf-server.test.ts create mode 100644 apps/viewer/src/services/bcf-server.ts create mode 100644 packages/bcf-api/README.md create mode 100644 packages/bcf-api/package.json create mode 100644 packages/bcf-api/src/auth.test.ts create mode 100644 packages/bcf-api/src/auth.ts create mode 100644 packages/bcf-api/src/client.test.ts create mode 100644 packages/bcf-api/src/client.ts create mode 100644 packages/bcf-api/src/errors.ts create mode 100644 packages/bcf-api/src/index.ts create mode 100644 packages/bcf-api/src/mapping.test.ts create mode 100644 packages/bcf-api/src/mapping.ts create mode 100644 packages/bcf-api/src/sync.test.ts create mode 100644 packages/bcf-api/src/sync.ts create mode 100644 packages/bcf-api/src/types.ts create mode 100644 packages/bcf-api/tsconfig.json create mode 100644 packages/bcf-api/vitest.config.ts diff --git a/.changeset/bcf-api-client.md b/.changeset/bcf-api-client.md new file mode 100644 index 0000000000..8be71d1065 --- /dev/null +++ b/.changeset/bcf-api-client.md @@ -0,0 +1,5 @@ +--- +'@ifc-lite/bcf-api': minor +--- + +New package: REST client for buildingSMART BCF API (OpenCDE) servers. Typed BCF API 2.1 endpoints (projects, extensions, topics with OData paging, comments, viewpoints, components, snapshots), OAuth2 password, refresh, and client-credentials grants against the server's discovered token endpoint, and `fetchProjectAsBCF` to pull a whole server project into the `@ifc-lite/bcf` in-memory model with per-item warning degradation. diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 3f0104e239..475266f581 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -28,36 +28,38 @@ "@codemirror/view": "^6.43.8", "@floating-ui/dom": "^1.8.0", "@ifc-lite/bcf": "workspace:^", + "@ifc-lite/bcf-api": "workspace:^", "@ifc-lite/cache": "workspace:^", - "@ifc-lite/collab": "workspace:^", "@ifc-lite/clash": "workspace:^", + "@ifc-lite/collab": "workspace:^", "@ifc-lite/create": "workspace:^", "@ifc-lite/data": "workspace:^", "@ifc-lite/diff": "workspace:^", - "@ifc-lite/ifcx": "workspace:^", - "@ifc-lite/merge": "workspace:^", "@ifc-lite/drawing-2d": "workspace:^", "@ifc-lite/encoding": "workspace:^", "@ifc-lite/export": "workspace:^", "@ifc-lite/extensions": "workspace:^", "@ifc-lite/geometry": "workspace:^", - "@ifc-lite/plugin-api": "workspace:^", "@ifc-lite/ids": "workspace:^", + "@ifc-lite/ifcx": "workspace:^", "@ifc-lite/lens": "workspace:^", "@ifc-lite/lists": "workspace:^", "@ifc-lite/mcp": "workspace:^", + "@ifc-lite/merge": "workspace:^", "@ifc-lite/mutations": "workspace:^", + "@ifc-lite/oauth-pkce": "workspace:^", "@ifc-lite/parser": "workspace:^", + "@ifc-lite/plugin-api": "workspace:^", "@ifc-lite/pointcloud": "workspace:^", "@ifc-lite/query": "workspace:^", - "@ifc-lite/source-dalux": "workspace:^", - "@ifc-lite/source-dropbox": "workspace:^", - "@ifc-lite/source-msgraph": "workspace:^", "@ifc-lite/renderer": "workspace:^", "@ifc-lite/sandbox": "workspace:^", "@ifc-lite/sdk": "workspace:^", "@ifc-lite/server-client": "workspace:^", "@ifc-lite/solar": "workspace:^", + "@ifc-lite/source-dalux": "workspace:^", + "@ifc-lite/source-dropbox": "workspace:^", + "@ifc-lite/source-msgraph": "workspace:^", "@ifc-lite/spatial": "workspace:^", "@ifc-lite/wasm": "workspace:^", "@radix-ui/react-collapsible": "^1.1.20", diff --git a/apps/viewer/public/oauth/bcf/callback.html b/apps/viewer/public/oauth/bcf/callback.html new file mode 100644 index 0000000000..4cd1daed9e --- /dev/null +++ b/apps/viewer/public/oauth/bcf/callback.html @@ -0,0 +1,98 @@ + + + + + + + BCF server sign-in + + + + +

Completing sign-in. You can close this window.

+ + + diff --git a/apps/viewer/src/components/viewer/BCFPanel.tsx b/apps/viewer/src/components/viewer/BCFPanel.tsx index f1b9ffa3d9..c0c9c93fdd 100644 --- a/apps/viewer/src/components/viewer/BCFPanel.tsx +++ b/apps/viewer/src/components/viewer/BCFPanel.tsx @@ -21,6 +21,7 @@ import { Download, User, MapPin, + Cloud, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { tourAnchor, TOUR_ANCHORS } from '@/lib/tours/anchors'; @@ -40,6 +41,7 @@ import { useBCF } from '@/hooks/useBCF'; import { BCFTopicList } from './bcf/BCFTopicList'; import { BCFTopicDetail } from './bcf/BCFTopicDetail'; import { BCFCreateTopicForm } from './bcf/BCFCreateTopicForm'; +import { BCFServerDialog } from './bcf/BCFServerDialog'; import { openGenericFileDialog } from '@/services/file-dialog'; import { downloadBlob, sanitizeFilename } from '@/lib/export/download'; @@ -100,6 +102,7 @@ export function BCFPanel({ onClose }: BCFPanelProps) { // Editing the active topic's fields in place (reuses the create form). (#1461) const [showEditForm, setShowEditForm] = useState(false); const [showAuthorDialog, setShowAuthorDialog] = useState(false); + const [showServerDialog, setShowServerDialog] = useState(false); const [tempAuthor, setTempAuthor] = useState(bcfAuthor); // Viewpoint previewed in the create form and attached to the new topic. const [createViewpoint, setCreateViewpoint] = useState(null); @@ -419,6 +422,15 @@ export function BCFPanel({ onClose }: BCFPanelProps) { > + + + + ); +} diff --git a/apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx new file mode 100644 index 0000000000..9aabba3381 --- /dev/null +++ b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx @@ -0,0 +1,282 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * BCF server dialog: the connect -> pick project -> load topics flow, with + * the server faked at the global fetch boundary. Assertions are on OUTPUT — + * the topics that land in the store's `bcfProject` and the author identity — + * not on wiring. + */ + +import '@/test/setup-dom.js'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { act } from 'react'; +import { render, cleanup, click } from '@/test/render.js'; +import { useViewerStore } from '@/store'; +import { clearBcfServerConfig } from '@/services/bcf-server'; +import type { BCFProject } from '@ifc-lite/bcf'; +import { BCFServerDialog } from './BCFServerDialog.js'; + +const realFetch = globalThis.fetch; + +/** One-project fake BCF server with two topics and no viewpoints. */ +function installFakeServer(): void { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const path = url.pathname; + if (path === '/bcf/2.1/auth') { + return json({ oauth2_token_url: 'https://fake.example/bcf/oauth2/token' }); + } + if (path === '/bcf/oauth2/token') { + const form = new URLSearchParams(String(init?.body)); + if (form.get('password') !== 'right') { + return json({ error: 'invalid_grant', error_description: 'bad credentials' }, 400); + } + return json({ access_token: 'token-1', expires_in: 3600 }); + } + if (path === '/bcf/2.1/current-user') return json({ id: 'tester@example.com' }); + if (path === '/bcf/2.1/projects') return json([{ project_id: 'p1', name: 'Project One' }]); + if (path === '/bcf/2.1/projects/p1') return json({ project_id: 'p1', name: 'Project One' }); + if (path.endsWith('/extensions')) return json({ topic_status: ['Open', 'Resolved'] }); + if (path.endsWith('/topics')) { + return json([ + { guid: 't1', title: 'First topic', topic_status: 'Open' }, + { guid: 't2', title: 'Second topic', topic_status: 'Resolved' }, + ]); + } + if (path.endsWith('/comments')) return json([{ guid: 'c1', comment: 'hi', author: 'a@b.c' }]); + if (path.endsWith('/viewpoints')) return json([]); + return json({ message: `unhandled ${path}` }, 500); + }) as typeof fetch; +} + +/** Poll until `predicate` holds, flushing React between checks. */ +async function waitFor(predicate: () => boolean, what: string): Promise { + for (let i = 0; i < 200; i++) { + if (predicate()) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + assert.ok(predicate(), `timed out waiting for: ${what}`); +} + +/** The dialog portals onto body, so controls are looked up there. */ +function input(id: string): HTMLInputElement { + const el = document.body.querySelector(`#${id}`); + assert.ok(el, `input #${id} must render`); + return el as HTMLInputElement; +} + +function button(label: string): HTMLButtonElement { + const el = [...document.body.querySelectorAll('button')].find( + (b) => b.textContent?.trim() === label, + ); + assert.ok(el, `button "${label}" must render`); + return el as HTMLButtonElement; +} + +/** Open a Radix Select by id and return its portaled options. */ +function openSelect(triggerId: string): HTMLElement[] { + const trigger = document.body.querySelector(`#${triggerId}`); + assert.ok(trigger, `select trigger #${triggerId} must render`); + act(() => { + trigger.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }), + ); + }); + return [...document.body.querySelectorAll('[role="option"]')] as HTMLElement[]; +} + +/** Commit a Radix Select option (Enter keydown, per Radix's keyboard model). */ +function chooseOption(triggerId: string, label: string): void { + const option = openSelect(triggerId).find((o) => o.textContent?.trim() === label); + assert.ok(option, `option "${label}" must be offered`); + act(() => { + option.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + }); +} + +/** Drive a React-controlled input the way typing does. */ +function type(el: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + )?.set; + assert.ok(setter, 'HTMLInputElement value setter must exist'); + act(() => { + setter.call(el, value); + el.dispatchEvent(new window.Event('input', { bubbles: true })); + }); +} + +beforeEach(() => { + clearBcfServerConfig(); + useViewerStore.setState({ bcfProject: null, bcfAuthor: 'user@example.com' }); +}); + +afterEach(() => { + cleanup(); + globalThis.fetch = realFetch; + clearBcfServerConfig(); + document.body.innerHTML = ''; +}); + +describe('BCFServerDialog', () => { + it('renders the sign-in form with Connect gated on all three fields', () => { + installFakeServer(); + render( {}} />); + assert.ok(input('bcf-server-url')); + assert.ok(input('bcf-server-user')); + assert.ok(input('bcf-server-password')); + assert.equal(button('Connect').disabled, true); + type(input('bcf-server-url'), 'https://fake.example/bcf'); + type(input('bcf-server-user'), 'tester@example.com'); + assert.equal(button('Connect').disabled, true, 'password still missing'); + type(input('bcf-server-password'), 'right'); + assert.equal(button('Connect').disabled, false); + }); + + it('signs in, pulls the selected project, and hydrates the BCF store', async () => { + installFakeServer(); + let openState = true; + render( (openState = open)} />); + type(input('bcf-server-url'), 'https://fake.example/bcf'); + type(input('bcf-server-user'), 'tester@example.com'); + type(input('bcf-server-password'), 'right'); + click(button('Connect')); + await waitFor( + () => document.body.textContent?.includes('Signed in as tester@example.com') ?? false, + 'signed-in banner', + ); + // Sign-in adopts the server identity as the BCF author. + assert.equal(useViewerStore.getState().bcfAuthor, 'tester@example.com'); + await waitFor( + () => document.body.textContent?.includes('Project One') ?? false, + 'project list', + ); + click(button('Load topics')); + await waitFor(() => useViewerStore.getState().bcfProject !== null, 'project in store'); + const project = useViewerStore.getState().bcfProject; + assert.equal(project?.topics.size, 2); + assert.equal(project?.topics.get('t1')?.title, 'First topic'); + assert.equal(project?.topics.get('t1')?.comments.length, 1); + assert.deepEqual(project?.extensions?.topicStatuses, ['Open', 'Resolved']); + assert.equal(openState, false, 'dialog closes after a successful pull'); + }); + + it('pre-fills the URL and narrows auth methods when a known public server is picked', () => { + installFakeServer(); + render( {}} />); + // Custom preset default: password fields shown. + assert.ok(document.body.querySelector('#bcf-server-password')); + chooseOption('bcf-server-preset', 'BIMData.io'); + assert.equal(input('bcf-server-url').value, 'https://api.bimdata.io/bcf'); + // Vendors default to the browser OAuth sign-in: password fields gone, + // the client-id field and the registered-redirect-URI hint shown, and a + // method dropdown offering the token-paste fallback. + assert.equal(document.body.querySelector('#bcf-server-password'), null); + assert.ok(document.body.querySelector('#bcf-server-oauth-client-id')); + assert.ok(document.body.textContent?.includes('/oauth/bcf/callback')); + assert.ok(document.body.querySelector('#bcf-server-auth')); + chooseOption('bcf-server-auth', 'Access token'); + assert.ok(document.body.querySelector('#bcf-server-token')); + assert.equal(document.body.querySelector('#bcf-server-oauth-client-id'), null); + }); + + it('connects with a pasted access token', async () => { + installFakeServer(); + render( {}} />); + chooseOption('bcf-server-auth', 'Access token'); + type(input('bcf-server-url'), 'https://fake.example/bcf'); + type(input('bcf-server-token'), 'pasted-token'); + click(button('Connect')); + await waitFor( + () => document.body.textContent?.includes('Signed in as tester@example.com') ?? false, + 'signed-in banner after token sign-in', + ); + await waitFor( + () => document.body.textContent?.includes('Project One') ?? false, + 'project list after token sign-in', + ); + }); + + it('requires a second, explicit click before replacing unsaved local topics', async () => { + installFakeServer(); + const localProject: BCFProject = { + version: '2.1', + topics: new Map([ + [ + 'local-1', + { + guid: 'local-1', + title: 'Unsaved local topic', + creationDate: '2026-08-25T10:00:00Z', + creationAuthor: 'user@example.com', + comments: [], + viewpoints: [], + }, + ], + ]), + }; + useViewerStore.setState({ bcfProject: localProject }); + render( {}} />); + type(input('bcf-server-url'), 'https://fake.example/bcf'); + type(input('bcf-server-user'), 'tester@example.com'); + type(input('bcf-server-password'), 'right'); + click(button('Connect')); + await waitFor( + () => document.body.textContent?.includes('Project One') ?? false, + 'project list', + ); + click(button('Load topics')); + await waitFor( + () => document.body.textContent?.includes('replace the 1 topic') ?? false, + 'replace warning', + ); + // First click must not have touched the local project. + assert.equal(useViewerStore.getState().bcfProject, localProject); + click(button('Replace and load')); + await waitFor( + () => useViewerStore.getState().bcfProject?.topics.has('t1') ?? false, + 'server project replaces local one after confirmation', + ); + assert.equal(useViewerStore.getState().bcfProject?.topics.size, 2); + }); + + it('shows the server rejection on bad credentials and stores nothing', async () => { + installFakeServer(); + render( {}} />); + type(input('bcf-server-url'), 'https://fake.example/bcf'); + type(input('bcf-server-user'), 'tester@example.com'); + type(input('bcf-server-password'), 'wrong'); + click(button('Connect')); + await waitFor( + () => document.body.textContent?.includes('bad credentials') ?? false, + 'error banner', + ); + assert.equal(useViewerStore.getState().bcfProject, null); + }); + + it('rejects a plain-http server URL before contacting it', async () => { + installFakeServer(); + render( {}} />); + type(input('bcf-server-url'), 'http://insecure.example/bcf'); + type(input('bcf-server-user'), 'tester@example.com'); + type(input('bcf-server-password'), 'right'); + click(button('Connect')); + await waitFor( + () => document.body.textContent?.includes('https://') ?? false, + 'https requirement message', + ); + }); +}); diff --git a/apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx new file mode 100644 index 0000000000..242788f154 --- /dev/null +++ b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx @@ -0,0 +1,273 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * BCF server connection dialog: sign in to a BCF API (OpenCDE) server (a + * known public one or a custom URL, via password grant, pasted access + * token, or client credentials — see BCFServerConnectForm), pick a + * project, and pull its topics into the BCF panel. Read path only — topics + * load into the same in-memory `BCFProject` the file import uses, so the + * whole existing BCF UI works on server data unchanged. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CheckCircle2, CloudDownload, Loader2, LogOut, XCircle } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from '@/components/ui/toast'; +import { useViewerStore } from '@/store'; +import { posthog } from '@/lib/analytics'; +import type { BcfProjectDto, BcfSyncProgress } from '@ifc-lite/bcf-api'; +import { + clearBcfServerConfig, + listBcfServerProjects, + loadBcfServerConfig, + pullBcfServerProject, + type BcfServerConfig, +} from '@/services/bcf-server'; +import { BCFServerConnectForm } from './BCFServerConnectForm'; + +interface BCFServerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BCFServerDialog({ open, onOpenChange }: BCFServerDialogProps) { + const setBcfProject = useViewerStore((s) => s.setBcfProject); + const setBcfAuthor = useViewerStore((s) => s.setBcfAuthor); + const setBcfError = useViewerStore((s) => s.setBcfError); + + const [config, setConfig] = useState(null); + const [initialServerUrl, setInitialServerUrl] = useState(''); + const [initialUsername, setInitialUsername] = useState(''); + const [projects, setProjects] = useState(null); + const [selectedProjectId, setSelectedProjectId] = useState(''); + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + // Number of local topics the next pull would replace; non-null means the + // user must confirm before we overwrite them (they may be unexported). + const [replaceCount, setReplaceCount] = useState(null); + + const loadProjects = useCallback(async () => { + try { + const list = await listBcfServerProjects(); + setProjects(list); + setSelectedProjectId((current) => { + if (current && list.some((p) => p.project_id === current)) return current; + return list[0]?.project_id ?? ''; + }); + } catch (err) { + setProjects([]); + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + // Re-seed from the saved connection only on the closed -> open transition, + // so parent re-renders never wipe in-progress typing. + const wasOpenRef = useRef(false); + useEffect(() => { + if (open && !wasOpenRef.current) { + const saved = loadBcfServerConfig(); + setConfig(saved); + setInitialServerUrl(saved?.serverUrl ?? ''); + setInitialUsername(saved?.userId ?? ''); + setProjects(null); + setSelectedProjectId(saved?.projectId ?? ''); + setError(null); + setProgress(null); + setReplaceCount(null); + if (saved) void loadProjects(); + } + wasOpenRef.current = open; + }, [open, loadProjects]); + + const handleSignedIn = useCallback( + (next: BcfServerConfig) => { + setConfig(next); + setError(null); + // Adopt the server identity as the BCF author only when it is + // email-shaped — some servers return an opaque id here, and topics + // authored under a GUID read as nobody in every other BCF tool. + if (next.userId.includes('@')) setBcfAuthor(next.userId); + void loadProjects(); + }, + [setBcfAuthor, loadProjects], + ); + + const handleDisconnect = useCallback(() => { + clearBcfServerConfig(); + setConfig(null); + setProjects(null); + setSelectedProjectId(''); + setError(null); + }, []); + + const handlePull = useCallback(async () => { + const project = projects?.find((p) => p.project_id === selectedProjectId); + if (!project) return; + // Loading replaces the panel's project wholesale; local topics may be + // unexported work, so overwriting them needs an explicit second click. + const localTopics = useViewerStore.getState().bcfProject?.topics.size ?? 0; + if (localTopics > 0 && replaceCount === null) { + setReplaceCount(localTopics); + return; + } + setReplaceCount(null); + setBusy(true); + setError(null); + setBcfError(null); + try { + const result = await pullBcfServerProject( + project.project_id, + project.name ?? 'BCF project', + setProgress, + ); + setBcfProject(result.project); + posthog.capture('bcf_server_synced', { + topic_count: result.project.topics.size, + warning_count: result.warnings.length, + }); + if (result.warnings.length > 0) { + console.warn('[bcf-server] sync warnings:', result.warnings); + toast.info( + `Loaded ${result.project.topics.size} topics (${result.warnings.length} items skipped — see console)`, + ); + } else { + toast.success(`Loaded ${result.project.topics.size} topics from the BCF server`); + } + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + setProgress(null); + } + }, [projects, selectedProjectId, replaceCount, setBcfProject, setBcfError, onOpenChange]); + + const connected = config !== null; + + return ( + + + + BCF Server + + + {/* DialogContent is a grid; without min-w-0 this grid item sizes to + the banner's nowrap min-content width and overflows the dialog + instead of letting the span truncate. */} +
+ {!connected ? ( + + ) : ( + <> +
+ + + Signed in as {config.userId} · {config.serverUrl} + +
+
+ + {projects === null ? ( +
+ + Loading projects… +
+ ) : projects.length === 0 ? ( +

No projects available.

+ ) : ( + + )} +
+ {progress && ( +

+ {progress.phase === 'topics' + ? `Fetching topics… ${progress.loaded}` + : `Loading topic details… ${progress.loaded}${progress.total ? ` / ${progress.total}` : ''}`} +

+ )} + {replaceCount !== null && ( +
+ Loading will replace the {replaceCount} topic{replaceCount === 1 ? '' : 's'}{' '} + currently in the BCF panel. Export them first if they are not saved anywhere. +
+ )} + {error && ( +
+ + {error} +
+ )} + + )} +
+ + {connected && ( + + + + + )} +
+
+ ); +} diff --git a/apps/viewer/src/components/viewer/bcf/bcf-server-presets.test.ts b/apps/viewer/src/components/viewer/bcf/bcf-server-presets.test.ts new file mode 100644 index 0000000000..83da400eb0 --- /dev/null +++ b/apps/viewer/src/components/viewer/bcf/bcf-server-presets.test.ts @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Data invariants of the server-preset catalogue. Every fixed URL must be a + * normalized https base (the form feeds it to normalizeBcfBaseUrl-consuming + * sign-in paths verbatim), ids must be unique (they key the dropdown), and + * each preset needs at least one auth method (the form defaults to the + * first). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { BCF_SERVER_PRESETS, CUSTOM_PRESET_ID, findBcfServerPreset, presetForServerUrl } from './bcf-server-presets.js'; + +describe('BCF_SERVER_PRESETS invariants', () => { + it('has unique ids and the custom entry first', () => { + const ids = BCF_SERVER_PRESETS.map((p) => p.id); + assert.equal(new Set(ids).size, ids.length, 'preset ids must be unique'); + assert.equal(BCF_SERVER_PRESETS[0].id, CUSTOM_PRESET_ID, 'custom is the fallback default'); + }); + + it('every fixed URL is a normalized https base (no trailing slash, no version segment)', () => { + for (const preset of BCF_SERVER_PRESETS) { + if (preset.baseUrl === '') continue; + assert.match(preset.baseUrl, /^https:\/\//, `${preset.id} must be https`); + assert.ok(!preset.baseUrl.endsWith('/'), `${preset.id} must not end with a slash`); + assert.ok(!/\/\d+\.\d+$/.test(preset.baseUrl), `${preset.id} must not embed a version`); + } + }); + + it('every preset offers at least one auth method', () => { + for (const preset of BCF_SERVER_PRESETS) { + assert.ok(preset.authMethods.length > 0, `${preset.id} needs an auth method`); + } + }); + + it('resolves saved connections back to their preset, and unknown ones to custom', () => { + assert.equal(presetForServerUrl('https://app.streambim.com/bcf').id, 'streambim'); + assert.equal(presetForServerUrl('https://my-own-server.example/bcf').id, CUSTOM_PRESET_ID); + assert.equal(findBcfServerPreset('does-not-exist').id, CUSTOM_PRESET_ID); + }); +}); diff --git a/apps/viewer/src/components/viewer/bcf/bcf-server-presets.ts b/apps/viewer/src/components/viewer/bcf/bcf-server-presets.ts new file mode 100644 index 0000000000..0186be62e7 --- /dev/null +++ b/apps/viewer/src/components/viewer/bcf/bcf-server-presets.ts @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Known BCF API servers, offered as a dropdown in the connect form. Every + * fixed URL below answered `GET {baseUrl}/versions` and `GET + * {baseUrl}/2.1/auth` when the list was compiled (2026-08-25); entries with + * an empty URL are tenant-hosted (the user supplies their instance). + * + * A preset pre-fills the server URL and narrows the auth methods to what + * the server's `/auth` discovery document advertises; "Custom" leaves + * everything open. Vendors advertise `authorization_code_grant`, so their + * default is 'oauth' — the in-browser sign-in popup — with 'token' (paste + * an access token) as the fallback. + */ + +export type BcfAuthMethod = 'password' | 'oauth' | 'token' | 'clientCredentials'; + +export interface BcfServerPreset { + id: string; + label: string; + /** Pre-filled base URL; empty when the user must supply their own. */ + baseUrl: string; + /** Auth methods this server is known to support, first one is default. */ + authMethods: readonly BcfAuthMethod[]; + /** OAuth `scope` this server's authorization endpoint expects, if any. */ + oauthScope?: string; + /** Short hint rendered under the server picker. */ + note?: string; +} + +export const CUSTOM_PRESET_ID = 'custom'; + +const VENDOR_NOTE = + 'Sign in with your vendor account in the browser (needs the client id of an OAuth app registered with the vendor), or paste an access token.'; + +export const BCF_SERVER_PRESETS: readonly BcfServerPreset[] = [ + { + id: CUSTOM_PRESET_ID, + label: 'Custom BCF server…', + baseUrl: '', + authMethods: ['password', 'oauth', 'token', 'clientCredentials'], + note: 'Any BCF API 2.1 server, e.g. https://example.com/bcf.', + }, + { + id: 'aconex-americas', + label: 'Aconex – Americas', + baseUrl: 'https://us1.aconex.com/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'aconex-asia', + label: 'Aconex – Asia', + baseUrl: 'https://asia1.aconex.com/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'aconex-aunz', + label: 'Aconex – Australia/NZ', + baseUrl: 'https://au1.aconex.com/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'aconex-europe', + label: 'Aconex – Europe', + baseUrl: 'https://eu1.aconex.com/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'aconex-hongkong', + label: 'Aconex – Hong Kong', + baseUrl: 'https://hk1.aconex.com/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'aconex-china', + label: 'Aconex – Mainland China', + baseUrl: 'https://cn1.aconexasia.com/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'aconex-saudi', + label: 'Aconex – Saudi Arabia', + baseUrl: 'https://ksa1.aconex.com/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'aconex-uk', + label: 'Aconex – United Kingdom', + baseUrl: 'https://uk1.aconex.co.uk/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'bimcollab', + label: 'BIMcollab', + baseUrl: '', + authMethods: ['oauth', 'token'], + note: 'Your space URL plus /bcf, e.g. https://myspace.bimcollab.com/bcf.', + }, + { + id: 'bimdata', + label: 'BIMData.io', + baseUrl: 'https://api.bimdata.io/bcf', + authMethods: ['oauth', 'token'], + note: 'Paste an access token from your BIMData account (developers.bimdata.io).', + }, + { + id: 'bimtrack', + label: 'BIM Track (Newforma Konekt)', + baseUrl: 'https://bcfrestapi.bimtrackapp.co/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'catenda', + label: 'Catenda Hub (Bimsync)', + baseUrl: 'https://api.catenda.com/opencde/bcf', + authMethods: ['oauth', 'token'], + note: 'Paste an access token from a Catenda OAuth application.', + }, + { + id: 'dalux', + label: 'Dalux Field', + baseUrl: 'https://field.dalux.com/service/bcf', + authMethods: ['oauth', 'token'], + note: VENDOR_NOTE, + }, + { + id: 'openproject', + label: 'OpenProject', + baseUrl: '', + authMethods: ['oauth', 'clientCredentials', 'token'], + note: 'Your instance URL plus /api/bcf, e.g. https://project.example.com/api/bcf. Create an OAuth application with client credentials in the OpenProject admin settings.', + }, + { + id: 'streambim', + label: 'StreamBIM', + baseUrl: 'https://app.streambim.com/bcf', + authMethods: ['oauth', 'token'], + // StreamBIM's authorization endpoint (AWS Cognito) rejects requests + // without an explicit scope; 'openid' is what its own integrations send. + oauthScope: 'openid', + note: VENDOR_NOTE, + }, +]; + +export function findBcfServerPreset(id: string): BcfServerPreset { + return BCF_SERVER_PRESETS.find((preset) => preset.id === id) ?? BCF_SERVER_PRESETS[0]; +} + +/** Preset whose pre-filled URL matches a saved connection, else custom. */ +export function presetForServerUrl(serverUrl: string): BcfServerPreset { + const match = BCF_SERVER_PRESETS.find( + (preset) => preset.baseUrl !== '' && preset.baseUrl === serverUrl, + ); + return match ?? findBcfServerPreset(CUSTOM_PRESET_ID); +} + +export const AUTH_METHOD_LABELS: Record = { + password: 'Email & password', + oauth: 'Sign in via browser', + token: 'Access token', + clientCredentials: 'Client ID & secret', +}; diff --git a/apps/viewer/src/services/bcf-server.test.ts b/apps/viewer/src/services/bcf-server.test.ts new file mode 100644 index 0000000000..360ccee7e8 --- /dev/null +++ b/apps/viewer/src/services/bcf-server.test.ts @@ -0,0 +1,409 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * BCF server connection service: URL validation, corrupted-storage + * degradation, the password-grant sign-in flow, and the transparent + * refresh of an expired access token. The BCF server is faked at the + * global fetch boundary, so everything from `@ifc-lite/bcf-api`'s request + * building through this service's persistence runs for real. + */ + +import '@/test/setup-dom.js'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + bcfOAuthRedirectUri, + clearBcfServerConfig, + completeBcfOAuth, + createConnectedClient, + listBcfServerProjects, + loadBcfServerConfig, + prepareBcfOAuth, + saveBcfServerConfig, + signInToBcfServer, + signInWithClientCredentials, + signInWithToken, + validateBcfServerUrl, +} from './bcf-server.js'; + +const realFetch = globalThis.fetch; + +interface FakeServerState { + /** Tokens the fake currently accepts on data routes. */ + validTokens: Set; + /** grant_type values seen at the token endpoint, in order. */ + grants: string[]; + /** When set, the token endpoint awaits this before answering. */ + tokenGate?: Promise; + /** When set, /auth advertises this token endpoint instead of the default. */ + advertisedTokenUrl?: string; + /** When false, /auth omits the dynamic client registration URL. */ + dynamicRegistration?: boolean; + /** Registration requests seen, for asserting what was sent. */ + registrations: Array>; +} + +/** Fake BCF server at the fetch boundary: auth discovery, OAuth2, projects. */ +function installFakeServer(): FakeServerState { + const state: FakeServerState = { + validTokens: new Set(['token-1']), + grants: [], + registrations: [], + }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname === '/bcf/2.1/auth') { + return json({ + oauth2_auth_url: 'https://fake.example/bcf/oauth2/auth', + oauth2_token_url: state.advertisedTokenUrl ?? 'https://fake.example/bcf/oauth2/token', + oauth2_dynamic_client_reg_url: + state.dynamicRegistration === false + ? undefined + : 'https://fake.example/bcf/oauth2/register', + }); + } + if (url.pathname === '/bcf/oauth2/register') { + state.registrations.push(JSON.parse(String(init?.body)) as Record); + return json({ client_id: 'minted-id', client_secret: 'minted-secret' }); + } + if (url.pathname === '/bcf/oauth2/token') { + if (state.tokenGate) await state.tokenGate; + const form = new URLSearchParams(String(init?.body)); + state.grants.push(form.get('grant_type') ?? ''); + if (form.get('grant_type') === 'authorization_code') { + if (form.get('code') !== 'good-code' || !form.get('code_verifier')) { + return json({ error: 'invalid_grant', error_description: 'bad code' }, 400); + } + } + if (form.get('grant_type') === 'password' && form.get('password') !== 'right') { + return json({ error: 'invalid_grant', error_description: 'bad credentials' }, 400); + } + if (form.get('grant_type') === 'refresh_token' && form.get('refresh_token') !== 'refresh-1') { + return json({ error: 'invalid_grant', error_description: 'bad refresh token' }, 400); + } + if ( + form.get('grant_type') === 'client_credentials' && + (form.get('client_id') !== 'app-id' || form.get('client_secret') !== 'app-secret') + ) { + return json({ error: 'invalid_client', error_description: 'bad app credentials' }, 401); + } + const token = `token-${state.grants.length}`; + state.validTokens.add(token); + return json({ + access_token: token, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-1', + }); + } + const auth = new Headers(init?.headers).get('Authorization') ?? ''; + if (!state.validTokens.has(auth.replace('Bearer ', ''))) { + return json({ message: 'Not authenticated' }, 401); + } + if (url.pathname === '/bcf/2.1/current-user') { + return json({ id: 'tester@example.com', name: 'Tester' }); + } + if (url.pathname === '/bcf/2.1/projects') { + return json([{ project_id: 'p1', name: 'Project One' }]); + } + return json({ message: `unhandled ${url.pathname}` }, 500); + }) as typeof fetch; + return state; +} + +beforeEach(() => { + clearBcfServerConfig(); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + clearBcfServerConfig(); +}); + +describe('validateBcfServerUrl', () => { + it('requires https except for local development hosts', () => { + assert.equal(validateBcfServerUrl('https://example.com/bcf'), null); + assert.equal(validateBcfServerUrl('http://localhost:8080/bcf'), null); + assert.equal(validateBcfServerUrl('http://127.0.0.1/bcf'), null); + assert.match(validateBcfServerUrl('http://example.com/bcf') ?? '', /https/); + assert.match(validateBcfServerUrl('not a url') ?? '', /full server URL/); + }); +}); + +describe('loadBcfServerConfig', () => { + it('degrades corrupted or partial storage to signed-out, never throws', () => { + localStorage.setItem('ifc-lite:bcf-server:v1', '{not json'); + assert.equal(loadBcfServerConfig(), null); + // A record without an access token is a broken session, not a connection. + localStorage.setItem('ifc-lite:bcf-server:v1', JSON.stringify({ serverUrl: 'https://x' })); + assert.equal(loadBcfServerConfig(), null); + }); +}); + +describe('signInToBcfServer', () => { + it('discovers the token endpoint, exchanges the password grant, and persists the session', async () => { + const server = installFakeServer(); + const config = await signInToBcfServer( + 'https://fake.example/bcf/2.1/', + 'tester@example.com', + 'right', + ); + assert.equal(config.serverUrl, 'https://fake.example/bcf'); + assert.equal(config.userId, 'tester@example.com'); + assert.equal(config.refreshToken, 'refresh-1'); + assert.ok(config.tokenExpiresAt > Date.now()); + assert.deepEqual(server.grants, ['password']); + // The persisted session drives later requests. + const projects = await listBcfServerProjects(); + assert.equal(projects[0]?.name, 'Project One'); + }); + + it('surfaces the server rejection message and stores nothing', async () => { + installFakeServer(); + await assert.rejects( + signInToBcfServer('https://fake.example/bcf', 'tester@example.com', 'wrong'), + /bad credentials/, + ); + assert.equal(loadBcfServerConfig(), null); + }); +}); + +describe('signInWithToken', () => { + it('validates a pasted token against current-user and persists a token-only session', async () => { + const server = installFakeServer(); + server.validTokens.add('pasted-token'); + const config = await signInWithToken('https://fake.example/bcf/', ' pasted-token '); + assert.equal(config.userId, 'tester@example.com'); + assert.equal(config.accessToken, 'pasted-token'); + assert.equal(config.refreshToken, ''); + assert.equal(config.tokenExpiresAt, 0); + assert.deepEqual(server.grants, [], 'no token endpoint involvement'); + const projects = await listBcfServerProjects(); + assert.equal(projects.length, 1); + }); + + it('rejects a token the server does not accept and stores nothing', async () => { + installFakeServer(); + await assert.rejects( + signInWithToken('https://fake.example/bcf', 'made-up-token'), + /Not authenticated/, + ); + assert.equal(loadBcfServerConfig(), null); + }); +}); + +describe('signInWithClientCredentials', () => { + it('exchanges the app credentials and persists them for later re-grants', async () => { + const server = installFakeServer(); + const config = await signInWithClientCredentials( + 'https://fake.example/bcf', + 'app-id', + 'app-secret', + ); + assert.equal(config.userId, 'tester@example.com'); + assert.equal(config.clientId, 'app-id'); + assert.equal(config.clientSecret, 'app-secret'); + assert.deepEqual(server.grants, ['client_credentials']); + }); + + it('surfaces bad app credentials and stores nothing', async () => { + installFakeServer(); + await assert.rejects( + signInWithClientCredentials('https://fake.example/bcf', 'app-id', 'wrong-secret'), + /bad app credentials/, + ); + assert.equal(loadBcfServerConfig(), null); + }); + + it('re-grants an expired token with the stored app credentials (no refresh token)', async () => { + const server = installFakeServer(); + saveBcfServerConfig({ + serverUrl: 'https://fake.example/bcf', + userId: 'tester@example.com', + accessToken: 'expired-token', + refreshToken: '', + tokenExpiresAt: Date.now() - 1000, + clientId: 'app-id', + clientSecret: 'app-secret', + projectId: '', + projectName: '', + }); + const client = await createConnectedClient(); + const projects = await client.getProjects(); + assert.equal(projects.length, 1); + assert.deepEqual(server.grants, ['client_credentials']); + const stored = loadBcfServerConfig(); + assert.notEqual(stored?.accessToken, 'expired-token'); + assert.equal(stored?.clientId, 'app-id', 'app credentials survive the re-grant'); + }); +}); + +describe('createConnectedClient token refresh', () => { + it('refreshes an expired access token before the request and persists the new token set', async () => { + const server = installFakeServer(); + saveBcfServerConfig({ + serverUrl: 'https://fake.example/bcf', + userId: 'tester@example.com', + accessToken: 'expired-token', + refreshToken: 'refresh-1', + tokenExpiresAt: Date.now() - 1000, + clientId: '', + clientSecret: '', + projectId: '', + projectName: '', + }); + const client = await createConnectedClient(); + const projects = await client.getProjects(); + assert.equal(projects.length, 1, 'request succeeds on the refreshed token'); + assert.deepEqual(server.grants, ['refresh_token']); + const stored = loadBcfServerConfig(); + assert.notEqual(stored?.accessToken, 'expired-token'); + assert.ok(stored && stored.tokenExpiresAt > Date.now()); + }); + + it('uses the stored token as-is while it is still fresh', async () => { + const server = installFakeServer(); + saveBcfServerConfig({ + serverUrl: 'https://fake.example/bcf', + userId: 'tester@example.com', + accessToken: 'token-1', + refreshToken: 'refresh-1', + tokenExpiresAt: Date.now() + 3_600_000, + clientId: '', + clientSecret: '', + projectId: '', + projectName: '', + }); + const client = await createConnectedClient(); + await client.getProjects(); + assert.deepEqual(server.grants, [], 'no token round-trip for a fresh session'); + }); + + it('recovers from a 401 by refreshing once and retrying (server omitted expires_in)', async () => { + // The stored token looks fresh by the clock but the server has revoked + // it; the 401 answer must trigger one refresh-and-retry, not an error. + const server = installFakeServer(); + saveBcfServerConfig({ + serverUrl: 'https://fake.example/bcf', + userId: 'tester@example.com', + accessToken: 'revoked-server-side', + refreshToken: 'refresh-1', + tokenExpiresAt: Date.now() + 3_600_000, + clientId: '', + clientSecret: '', + projectId: '', + projectName: '', + }); + const client = await createConnectedClient(); + const projects = await client.getProjects(); + assert.equal(projects.length, 1); + assert.deepEqual(server.grants, ['refresh_token']); + }); + + it('does not resurrect the session when the user disconnects during a refresh', async () => { + const server = installFakeServer(); + let openGate = () => {}; + server.tokenGate = new Promise((resolve) => { + openGate = resolve; + }); + saveBcfServerConfig({ + serverUrl: 'https://fake.example/bcf', + userId: 'tester@example.com', + accessToken: 'expired-token', + refreshToken: 'refresh-1', + tokenExpiresAt: Date.now() - 1000, + clientId: '', + clientSecret: '', + projectId: '', + projectName: '', + }); + const client = await createConnectedClient(); + const pending = client.getProjects(); + // Sign-out lands while the refresh round-trip is still in flight. + clearBcfServerConfig(); + openGate(); + await pending; + assert.equal(loadBcfServerConfig(), null, 'sign-out must survive the completed refresh'); + }); +}); + +describe('prepareBcfOAuth', () => { + it('builds the authorization URL with PKCE from an explicit client id, skipping registration', async () => { + const server = installFakeServer(); + const preparation = await prepareBcfOAuth('https://fake.example/bcf', { + clientId: 'my-app', + scope: 'openid', + }); + const url = new URL(preparation.authorizeUrl); + assert.equal(url.origin + url.pathname, 'https://fake.example/bcf/oauth2/auth'); + assert.equal(url.searchParams.get('response_type'), 'code'); + assert.equal(url.searchParams.get('client_id'), 'my-app'); + assert.equal(url.searchParams.get('redirect_uri'), bcfOAuthRedirectUri()); + assert.equal(url.searchParams.get('scope'), 'openid'); + assert.equal(url.searchParams.get('code_challenge_method'), 'S256'); + assert.ok(url.searchParams.get('code_challenge')); + assert.equal(url.searchParams.get('state'), preparation.state); + assert.equal(server.registrations.length, 0); + }); + + it('mints a client via dynamic registration when no client id is given', async () => { + const server = installFakeServer(); + const preparation = await prepareBcfOAuth('https://fake.example/bcf'); + assert.equal(preparation.clientId, 'minted-id'); + assert.equal(preparation.clientSecret, 'minted-secret'); + assert.equal(server.registrations.length, 1); + assert.equal(server.registrations[0].redirect_url, bcfOAuthRedirectUri()); + assert.equal( + new URL(preparation.authorizeUrl).searchParams.get('client_id'), + 'minted-id', + ); + }); + + it('explains what is needed when the server offers no registration and no id was given', async () => { + const server = installFakeServer(); + server.dynamicRegistration = false; + await assert.rejects(prepareBcfOAuth('https://fake.example/bcf'), /needs a Client ID/); + }); +}); + +describe('completeBcfOAuth', () => { + it('validates the callback, exchanges the code, and persists the session with app credentials', async () => { + const server = installFakeServer(); + const preparation = await prepareBcfOAuth('https://fake.example/bcf'); + const callbackUrl = `${bcfOAuthRedirectUri()}?code=good-code&state=${preparation.state}`; + const config = await completeBcfOAuth(preparation, callbackUrl); + assert.equal(config.userId, 'tester@example.com'); + assert.equal(config.clientId, 'minted-id'); + assert.equal(config.clientSecret, 'minted-secret'); + assert.deepEqual(server.grants, ['authorization_code']); + assert.ok(loadBcfServerConfig(), 'session persisted'); + }); + + it('rejects a callback whose state does not match this attempt', async () => { + installFakeServer(); + const preparation = await prepareBcfOAuth('https://fake.example/bcf'); + const forged = `${bcfOAuthRedirectUri()}?code=good-code&state=someone-elses-state`; + await assert.rejects(completeBcfOAuth(preparation, forged)); + assert.equal(loadBcfServerConfig(), null); + }); +}); + +describe('discovered token endpoint TLS enforcement', () => { + it('refuses to send the password to a plain-http token endpoint', async () => { + const server = installFakeServer(); + server.advertisedTokenUrl = 'http://fake.example/bcf/oauth2/token'; + await assert.rejects( + signInToBcfServer('https://fake.example/bcf', 'tester@example.com', 'right'), + /insecure token endpoint/, + ); + assert.deepEqual(server.grants, [], 'no credentials may reach the endpoint'); + assert.equal(loadBcfServerConfig(), null); + }); +}); diff --git a/apps/viewer/src/services/bcf-server.ts b/apps/viewer/src/services/bcf-server.ts new file mode 100644 index 0000000000..dfbdbd6650 --- /dev/null +++ b/apps/viewer/src/services/bcf-server.ts @@ -0,0 +1,458 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * BCF server connection state. + * + * Persists the server URL, signed-in user, and OAuth2 token set in + * localStorage (unencrypted, like other BYOK values — revocable, not + * secret). The password is sent once to the server's token endpoint for the + * OAuth2 password grant and never stored. `@ifc-lite/bcf-api` is loaded via + * dynamic import so the connector code stays out of the entry bundle until + * a user actually connects. + */ + +import type { BcfApiClient, BcfProjectDto, BcfProjectFetchResult, BcfSyncProgress } from '@ifc-lite/bcf-api'; + +export interface BcfServerConfig { + /** Normalized base URL, e.g. https://host/bcf (no version segment). */ + serverUrl: string; + /** Signed-in user id (email) from `current-user`. */ + userId: string; + accessToken: string; + refreshToken: string; + /** Epoch ms when the access token expires; 0 when the server didn't say. */ + tokenExpiresAt: number; + /** + * OAuth app credentials, kept only for client-credentials connections so + * an expired access token can be re-granted without user interaction. + */ + clientId: string; + clientSecret: string; + /** Last-selected project, so reconnects re-sync without re-picking. */ + projectId: string; + projectName: string; +} + +const STORAGE_KEY = 'ifc-lite:bcf-server:v1'; +const CHANGED_EVENT = 'ifc-lite:bcf-server-changed'; +/** Refresh the access token this many ms before its recorded expiry. */ +const EXPIRY_SKEW_MS = 60_000; + +function loadApi() { + return import('@ifc-lite/bcf-api'); +} + +function sanitize(value: unknown): BcfServerConfig | null { + if (!value || typeof value !== 'object') return null; + const parsed = value as Partial; + if (typeof parsed.serverUrl !== 'string' || parsed.serverUrl.length === 0) return null; + if (typeof parsed.accessToken !== 'string' || parsed.accessToken.length === 0) return null; + return { + serverUrl: parsed.serverUrl, + userId: typeof parsed.userId === 'string' ? parsed.userId : '', + accessToken: parsed.accessToken, + refreshToken: typeof parsed.refreshToken === 'string' ? parsed.refreshToken : '', + tokenExpiresAt: typeof parsed.tokenExpiresAt === 'number' ? parsed.tokenExpiresAt : 0, + clientId: typeof parsed.clientId === 'string' ? parsed.clientId : '', + clientSecret: typeof parsed.clientSecret === 'string' ? parsed.clientSecret : '', + projectId: typeof parsed.projectId === 'string' ? parsed.projectId : '', + projectName: typeof parsed.projectName === 'string' ? parsed.projectName : '', + }; +} + +export function loadBcfServerConfig(): BcfServerConfig | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? sanitize(JSON.parse(raw)) : null; + } catch (error) { + // Storage blocked or corrupted entry: treat as signed out. + console.warn('[bcf-server] failed to read saved connection', error); + return null; + } +} + +export function saveBcfServerConfig(config: BcfServerConfig): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + } catch (error) { + // Storage blocked or full: the in-memory session still works for this + // page; only reconnect-after-reload is lost. Never fail the caller's + // operation (a completed pull) over bookkeeping. + console.warn('[bcf-server] failed to persist connection', error); + } + window.dispatchEvent(new Event(CHANGED_EVENT)); +} + +export function clearBcfServerConfig(): void { + localStorage.removeItem(STORAGE_KEY); + window.dispatchEvent(new Event(CHANGED_EVENT)); +} + +export function subscribeBcfServer(listener: () => void): () => void { + window.addEventListener(CHANGED_EVENT, listener); + return () => window.removeEventListener(CHANGED_EVENT, listener); +} + +/** + * BCF servers hold real project data, so require TLS for anything that is + * not local development. + */ +export function validateBcfServerUrl(input: string): string | null { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + return 'Enter the full server URL, e.g. https://example.com/bcf'; + } + const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; + if (url.protocol !== 'https:' && !isLocalhost) { + return 'BCF server URLs must use https://'; + } + return null; +} + +/** + * The token endpoint comes from the server's /auth discovery document, not + * from the user — it receives the password and refresh token, so it must + * pass the same TLS rule or the https guarantee on the server URL is + * meaningless. (A different host is legitimate: OAuth servers often live on + * their own domain.) + */ +function requireSecureTokenUrl(tokenUrl: string | undefined): string { + if (!tokenUrl) { + throw new Error('This BCF server does not advertise an OAuth2 token endpoint'); + } + const problem = validateBcfServerUrl(tokenUrl); + if (problem) { + throw new Error(`This BCF server advertises an insecure token endpoint (${problem})`); + } + return tokenUrl; +} + +/** + * Resolve the signed-in identity for a fresh token set, persist the + * connection, and hand it back. Shared tail of every sign-in flow. + */ +async function completeSignIn( + baseUrl: string, + token: { access_token: string; refresh_token?: string; expires_in?: number }, + appCredentials?: { clientId: string; clientSecret: string }, +): Promise { + const api = await loadApi(); + const client = new api.BcfApiClient({ baseUrl, getAccessToken: () => token.access_token }); + const user = await client.getCurrentUser(); + const config: BcfServerConfig = { + serverUrl: baseUrl, + userId: user.id, + accessToken: token.access_token, + refreshToken: token.refresh_token ?? '', + tokenExpiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : 0, + clientId: appCredentials?.clientId ?? '', + clientSecret: appCredentials?.clientSecret ?? '', + projectId: '', + projectName: '', + }; + saveBcfServerConfig(config); + return config; +} + +/** + * Sign in with the OAuth2 resource-owner password grant: discover the token + * endpoint from the server's `/auth` document, exchange the credentials for + * a token set, resolve the user identity, and persist the connection. + */ +export async function signInToBcfServer( + serverUrl: string, + username: string, + password: string, +): Promise { + const api = await loadApi(); + const baseUrl = api.normalizeBcfBaseUrl(serverUrl); + const anonymous = new api.BcfApiClient({ baseUrl }); + const authInfo = await anonymous.getAuthInfo(); + const token = await api.requestPasswordToken({ + tokenUrl: requireSecureTokenUrl(authInfo.oauth2_token_url), + username, + password, + }); + return completeSignIn(baseUrl, token); +} + +/** + * Sign in with a user-supplied access token (obtained from the server's own + * UI or OAuth tooling). Works against servers that only offer the + * authorization-code flow; there is no refresh material, so the session + * ends when the token expires. + */ +export async function signInWithToken( + serverUrl: string, + accessToken: string, +): Promise { + const api = await loadApi(); + const baseUrl = api.normalizeBcfBaseUrl(serverUrl); + return completeSignIn(baseUrl, { access_token: accessToken.trim() }); +} + +/** Path the popup returns to; must match what OAuth apps register. */ +export const BCF_OAUTH_REDIRECT_PATH = '/oauth/bcf/callback'; + +/** Absolute redirect URI for this deployment, shown to users for app registration. */ +export function bcfOAuthRedirectUri(): string { + return `${window.location.origin}${BCF_OAUTH_REDIRECT_PATH}`; +} + +/** Everything one browser sign-in attempt needs across the popup round-trip. */ +export interface BcfOAuthPreparation { + serverUrl: string; + tokenUrl: string; + /** Full authorization URL to navigate the popup to. */ + authorizeUrl: string; + state: string; + codeVerifier: string; + clientId: string; + clientSecret: string; +} + +/** + * First half of the browser OAuth sign-in: discover the server's endpoints, + * resolve a client id — the caller's own, or one minted on the spot where + * the server offers dynamic client registration — and build the + * authorization URL (with PKCE; servers that ignore the challenge still + * accept the exchange). + */ +export async function prepareBcfOAuth( + serverUrl: string, + options: { clientId?: string; clientSecret?: string; scope?: string } = {}, +): Promise { + const api = await loadApi(); + const baseUrl = api.normalizeBcfBaseUrl(serverUrl); + const authInfo = await new api.BcfApiClient({ baseUrl }).getAuthInfo(); + const tokenUrl = requireSecureTokenUrl(authInfo.oauth2_token_url); + const authEndpoint = authInfo.oauth2_auth_url; + if (!authEndpoint) { + throw new Error('This BCF server does not advertise an OAuth2 authorization endpoint'); + } + const endpointProblem = validateBcfServerUrl(authEndpoint); + if (endpointProblem) { + throw new Error(`This BCF server advertises an insecure authorization endpoint (${endpointProblem})`); + } + + let clientId = options.clientId?.trim() ?? ''; + let clientSecret = options.clientSecret?.trim() ?? ''; + if (!clientId) { + if (!authInfo.oauth2_dynamic_client_reg_url) { + throw new Error( + 'This server needs a Client ID: register an OAuth application with the vendor and enter its client id.', + ); + } + const registered = await api.registerBcfClient({ + registrationUrl: authInfo.oauth2_dynamic_client_reg_url, + clientName: 'IFClite viewer', + clientUrl: window.location.origin, + redirectUrl: bcfOAuthRedirectUri(), + }); + clientId = registered.client_id; + clientSecret = registered.client_secret ?? ''; + } + + const { createAuthorizationRequest } = await import('@ifc-lite/oauth-pkce'); + const request = await createAuthorizationRequest({ + authorizationEndpoint: authEndpoint, + clientId, + redirectUri: bcfOAuthRedirectUri(), + scope: options.scope, + }); + return { + serverUrl: baseUrl, + tokenUrl, + authorizeUrl: request.url, + state: request.state, + codeVerifier: request.codeVerifier, + clientId, + clientSecret, + }; +} + +/** + * Second half of the browser OAuth sign-in: validate the popup's callback + * URL (origin, provider error, state, code), exchange the code, resolve + * the identity, and persist the session. The client id/secret are stored + * so token refreshes can authenticate. + */ +export async function completeBcfOAuth( + preparation: BcfOAuthPreparation, + callbackUrl: string, +): Promise { + const api = await loadApi(); + const { parseAuthorizationCallback } = await import('@ifc-lite/oauth-pkce'); + const redirectUri = bcfOAuthRedirectUri(); + const { code } = parseAuthorizationCallback(callbackUrl, { + expectedRedirectOrigin: new URL(redirectUri).origin, + expectedState: preparation.state, + }); + const token = await api.exchangeAuthorizationCode({ + tokenUrl: preparation.tokenUrl, + code, + redirectUri, + codeVerifier: preparation.codeVerifier, + clientId: preparation.clientId, + clientSecret: preparation.clientSecret || undefined, + }); + return completeSignIn(preparation.serverUrl, token, { + clientId: preparation.clientId, + clientSecret: preparation.clientSecret, + }); +} + +/** + * Sign in with an OAuth application's client-credentials grant (e.g. an + * OpenProject OAuth app). The id/secret are persisted so an expired access + * token re-grants without user interaction. + */ +export async function signInWithClientCredentials( + serverUrl: string, + clientId: string, + clientSecret: string, +): Promise { + const api = await loadApi(); + const baseUrl = api.normalizeBcfBaseUrl(serverUrl); + const anonymous = new api.BcfApiClient({ baseUrl }); + const authInfo = await anonymous.getAuthInfo(); + const token = await api.requestClientCredentialsToken({ + tokenUrl: requireSecureTokenUrl(authInfo.oauth2_token_url), + clientId, + clientSecret, + }); + return completeSignIn(baseUrl, token, { clientId, clientSecret }); +} + +/** Single-flight token refresh so concurrent requests share one round-trip. */ +let refreshInFlight: Promise | null = null; + +/** Whether a stored connection has any material to re-authenticate with. */ +function canReauthenticate(config: BcfServerConfig): boolean { + return config.refreshToken.length > 0 || (config.clientId.length > 0 && config.clientSecret.length > 0); +} + +async function refreshStoredToken(config: BcfServerConfig): Promise { + if (!refreshInFlight) { + refreshInFlight = (async () => { + const api = await loadApi(); + const anonymous = new api.BcfApiClient({ baseUrl: config.serverUrl }); + const authInfo = await anonymous.getAuthInfo(); + if (!canReauthenticate(config)) { + throw new api.BcfAuthenticationError('Session expired — sign in again', { + status: 401, + url: config.serverUrl, + }); + } + const tokenUrl = requireSecureTokenUrl(authInfo.oauth2_token_url); + // OAuth-app sessions must present the app credentials on the refresh + // grant too; token servers that never issued a client ignore them. + const token = config.refreshToken + ? await api.refreshAccessToken({ + tokenUrl, + refreshToken: config.refreshToken, + clientId: config.clientId || undefined, + clientSecret: config.clientSecret || undefined, + }) + : await api.requestClientCredentialsToken({ + tokenUrl, + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + // Persist only when the stored connection is still THIS one. If the + // user disconnected (sign-out is their revocation gesture) or switched + // servers while the refresh was in flight, re-saving would resurrect + // the session or write this server's tokens under the other server's + // URL. The in-flight caller still gets the fresh token either way. + const current = loadBcfServerConfig(); + if (current && current.serverUrl === config.serverUrl) { + saveBcfServerConfig({ + ...current, + accessToken: token.access_token, + refreshToken: token.refresh_token ?? config.refreshToken, + tokenExpiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : 0, + }); + } + return token.access_token; + })().finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + +/** + * Client bound to the saved connection. Its token provider transparently + * refreshes the access token shortly before expiry, and a 401 answer gets + * one refresh-and-retry (covers servers that omit expires_in); a failed + * refresh surfaces as a BcfAuthenticationError from the request that + * needed it. + */ +export async function createConnectedClient(): Promise { + const api = await loadApi(); + const config = loadBcfServerConfig(); + if (!config) throw new Error('Not connected to a BCF server'); + // Storage can change mid-pull (another tab disconnects or connects to a + // different server). This client is bound to config.serverUrl, so only a + // stored config for the SAME server may feed it tokens — anything else + // would send the new server's token to the old server's host. + const boundConfig = (): BcfServerConfig => { + const current = loadBcfServerConfig(); + return current && current.serverUrl === config.serverUrl ? current : config; + }; + return new api.BcfApiClient({ + baseUrl: config.serverUrl, + getAccessToken: async () => { + const current = boundConfig(); + const expiring = + current.tokenExpiresAt > 0 && Date.now() > current.tokenExpiresAt - EXPIRY_SKEW_MS; + if (expiring && canReauthenticate(current)) { + return refreshStoredToken(current); + } + return current.accessToken; + }, + fetchFn: async (input, init) => { + const response = await fetch(input, init); + if (response.status !== 401) return response; + const current = boundConfig(); + if (!canReauthenticate(current)) return response; + let freshToken: string; + try { + freshToken = await refreshStoredToken(current); + } catch (error) { + console.warn('[bcf-server] token refresh after 401 failed', error); + return response; + } + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${freshToken}`); + return fetch(input, { ...init, headers }); + }, + }); +} + +export async function listBcfServerProjects(): Promise { + const client = await createConnectedClient(); + return client.getProjects(); +} + +/** + * Pull one project's topics into a `BCFProject` and remember it as the + * connection's active project. + */ +export async function pullBcfServerProject( + projectId: string, + projectName: string, + onProgress?: (progress: BcfSyncProgress) => void, +): Promise { + const api = await loadApi(); + const client = await createConnectedClient(); + const result = await api.fetchProjectAsBCF(client, projectId, { onProgress }); + if (!result.project.name) result.project.name = projectName; + const config = loadBcfServerConfig(); + if (config) saveBcfServerConfig({ ...config, projectId, projectName }); + return result; +} diff --git a/apps/viewer/tsconfig.json b/apps/viewer/tsconfig.json index 14f07fee90..ab11f625d5 100644 --- a/apps/viewer/tsconfig.json +++ b/apps/viewer/tsconfig.json @@ -21,6 +21,8 @@ "@ifc-lite/sdk": ["../../packages/sdk/src"], "@ifc-lite/lens": ["../../packages/lens/src"], "@ifc-lite/bcf": ["../../packages/bcf/src"], + "@ifc-lite/bcf-api": ["../../packages/bcf-api/src"], + "@ifc-lite/oauth-pkce": ["../../packages/oauth-pkce/src"], "@ifc-lite/mutations": ["../../packages/mutations/src"], "@ifc-lite/drawing-2d": ["../../packages/drawing-2d/src"], "@ifc-lite/server-client": ["../../packages/server-client/src"], diff --git a/apps/viewer/vite-plugins/oauth-callback.ts b/apps/viewer/vite-plugins/oauth-callback.ts index de3b664b04..1a0d1a552e 100644 --- a/apps/viewer/vite-plugins/oauth-callback.ts +++ b/apps/viewer/vite-plugins/oauth-callback.ts @@ -22,6 +22,8 @@ const CALLBACK_PAGES: Record = { '/oauth/dropbox/callback': '/oauth/dropbox/callback.html', // @ifc-lite/source-msgraph, REDIRECT_PATH in its src/auth.ts. '/oauth/msgraph/callback': '/oauth/msgraph/callback.html', + // BCF server connector, BCF_OAUTH_REDIRECT_PATH in src/services/bcf-server.ts. + '/oauth/bcf/callback': '/oauth/bcf/callback.html', }; export function oauthCallbackRoutes(): Plugin { diff --git a/apps/viewer/vite.config.ts b/apps/viewer/vite.config.ts index 307c509d4d..d8da127e53 100644 --- a/apps/viewer/vite.config.ts +++ b/apps/viewer/vite.config.ts @@ -283,6 +283,8 @@ export default defineConfig({ '@ifc-lite/lens': path.resolve(__dirname, '../../packages/lens/src'), '@ifc-lite/mutations': path.resolve(__dirname, '../../packages/mutations/src'), '@ifc-lite/bcf': path.resolve(__dirname, '../../packages/bcf/src'), + '@ifc-lite/bcf-api': path.resolve(__dirname, '../../packages/bcf-api/src'), + '@ifc-lite/oauth-pkce': path.resolve(__dirname, '../../packages/oauth-pkce/src'), '@ifc-lite/drawing-2d': path.resolve(__dirname, '../../packages/drawing-2d/src'), '@ifc-lite/encoding': path.resolve(__dirname, '../../packages/encoding/src'), '@ifc-lite/ids': path.resolve(__dirname, '../../packages/ids/src'), @@ -348,6 +350,7 @@ export default defineConfig({ if (id.includes('/packages/sandbox/')) return 'sandbox'; if (id.includes('/packages/export/')) return 'exporters'; if (id.includes('/packages/server-client/')) return 'server-client'; + if (id.includes('/packages/bcf-api/')) return 'bcf-api'; if (id.includes('/packages/bcf/')) return 'bcf'; if (id.includes('/packages/ids/')) return 'ids'; if (id.includes('/packages/lens/')) return 'lens'; diff --git a/docs/api/typescript.md b/docs/api/typescript.md index b1e816367e..1b1756ef05 100644 --- a/docs/api/typescript.md +++ b/docs/api/typescript.md @@ -2,7 +2,7 @@ API documentation for the published TypeScript packages. -ifc-lite ships 36 public npm packages: 35 scoped `@ifc-lite/*` packages plus the `create-ifc-lite` scaffolder. This page lists all of them, with API detail for the core packages. Exact type signatures live in each package's `src/index.ts` and shipped `.d.ts` files. +ifc-lite ships its public npm packages under the `@ifc-lite/*` scope, plus the `create-ifc-lite` scaffolder. This page lists all of them, with API detail for the core packages. Exact type signatures live in each package's `src/index.ts` and shipped `.d.ts` files. ## Package Index @@ -45,6 +45,7 @@ ifc-lite ships 36 public npm packages: 35 scoped `@ifc-lite/*` packages plus the | [`@ifc-lite/wasm`](#ifc-litewasm) | WebAssembly bindings for IFC-Lite | | [`@ifc-lite/codegen`](#ifc-litecodegen) | TypeScript code generator from IFC EXPRESS schemas | | [`create-ifc-lite`](#create-ifc-lite) | Create IFC-Lite projects with one command | +| [`@ifc-lite/bcf-api`](https://www.npmjs.com/package/@ifc-lite/bcf-api) | BCF API (OpenCDE) REST client for connecting to BCF servers | | [`@ifc-lite/merge`](https://www.npmjs.com/package/@ifc-lite/merge) | Three-way merge engine for IFCX layers — MergePlan with auto-merged ops and explicit conflict records, merge-layer emission, rebase, and revert. | | [`@ifc-lite/oauth-pkce`](https://www.npmjs.com/package/@ifc-lite/oauth-pkce) | Browser OAuth 2.0 Authorization Code + PKCE flow, shared by ifc-lite's file-source providers | | [`@ifc-lite/plugin-api`](https://www.npmjs.com/package/@ifc-lite/plugin-api) | Dependency-free type surface for ifc-lite file-source plugins | diff --git a/docs/guide/bcf.md b/docs/guide/bcf.md index cb5b7db96c..d7bc45b808 100644 --- a/docs/guide/bcf.md +++ b/docs/guide/bcf.md @@ -163,16 +163,50 @@ For rendering BCF topics as markers in a 3D view, the package provides viewer-ag import { computeMarkerPositions, BCFOverlayRenderer } from '@ifc-lite/bcf'; ``` +## BCF Servers (BCF API) + +Beyond `.bcfzip` files, the `@ifc-lite/bcf-api` package connects to [buildingSMART BCF API](https://github.com/buildingSMART/BCF-API) (OpenCDE) servers and pulls their topics into the same `BCFProject` model: + +```typescript +import { + BcfApiClient, + normalizeBcfBaseUrl, + requestPasswordToken, + fetchProjectAsBCF, +} from '@ifc-lite/bcf-api'; + +const baseUrl = normalizeBcfBaseUrl('https://example.com/bcf'); + +// Discover the token endpoint, then sign in with the OAuth2 password grant +const auth = await new BcfApiClient({ baseUrl }).getAuthInfo(); +const token = await requestPasswordToken({ + tokenUrl: auth.oauth2_token_url!, + username: 'you@example.com', + password: 'secret', +}); + +const client = new BcfApiClient({ baseUrl, getAccessToken: () => token.access_token }); +const projects = await client.getProjects(); + +// Topics, comments, viewpoints (cameras, selection, coloring, visibility) +// and snapshots, assembled into an @ifc-lite/bcf BCFProject +const { project, warnings } = await fetchProjectAsBCF(client, projects[0].project_id); +console.log(`Pulled ${project.topics.size} topics (${warnings.length} warnings)`); +``` + +The client implements the BCF API 2.1 routes (projects, extensions, topics with OData paging, comments, viewpoints, component subresources, snapshots). Per-item failures — a missing snapshot, an unreadable components resource — degrade to `warnings` entries instead of failing the pull. + ## Viewer Integration In the IFClite viewer, BCF is integrated through the BCF panel: 1. **Load BCF** - Drag and drop a `.bcf` or `.bcfzip` file or use the BCF panel to import -2. **Browse Topics** - View all issues with status, priority, and labels -3. **Navigate Viewpoints** - Click a viewpoint to restore camera and visibility -4. **Add Comments** - Discuss issues directly in the viewer -5. **Create Topics** - Select entities, position camera, and create new issues -6. **Export BCF** - Save the project as a `.bcfzip` file for sharing +2. **Connect to a BCF server** - The cloud button in the panel header connects to a BCF API server — pick a known server (Aconex regions, BIMcollab, BIMData.io, BIM Track/Newforma Konekt, Catenda Hub, Dalux Field, OpenProject, StreamBIM) or enter a custom URL, sign in with email & password, a pasted access token, or OAuth client credentials — then list its projects and load topics, viewpoints, and snapshots straight into the panel +3. **Browse Topics** - View all issues with status, priority, and labels +4. **Navigate Viewpoints** - Click a viewpoint to restore camera and visibility +5. **Add Comments** - Discuss issues directly in the viewer +6. **Create Topics** - Select entities, position camera, and create new issues +7. **Export BCF** - Save the project as a `.bcfzip` file for sharing ## Key Types diff --git a/packages/bcf-api/README.md b/packages/bcf-api/README.md new file mode 100644 index 0000000000..4fdf7143ad --- /dev/null +++ b/packages/bcf-api/README.md @@ -0,0 +1,68 @@ +# @ifc-lite/bcf-api + +REST client for [buildingSMART BCF API](https://github.com/buildingSMART/BCF-API) (OpenCDE) servers. Connects to a BCF server, authenticates via OAuth2, and pulls projects, topics, comments and viewpoints into the [`@ifc-lite/bcf`](https://www.npmjs.com/package/@ifc-lite/bcf) in-memory model — so server-hosted issues flow through the same code paths as imported `.bcfzip` files. + +Works in the browser and in Node (uses the global `fetch`; injectable for tests). Implements the BCF API 2.1 routes. + +## Install + +```bash +npm install @ifc-lite/bcf-api @ifc-lite/bcf +``` + +## Sign in and pull a project + +```ts +import { + BcfApiClient, + normalizeBcfBaseUrl, + requestPasswordToken, + fetchProjectAsBCF, +} from '@ifc-lite/bcf-api'; + +const baseUrl = normalizeBcfBaseUrl('https://example.com/bcf'); + +// Discover the token endpoint, then use the OAuth2 password grant +const auth = await new BcfApiClient({ baseUrl }).getAuthInfo(); +const token = await requestPasswordToken({ + tokenUrl: auth.oauth2_token_url!, + username: 'you@example.com', + password: '...', +}); + +const client = new BcfApiClient({ + baseUrl, + getAccessToken: () => token.access_token, +}); + +const projects = await client.getProjects(); +const { project, warnings } = await fetchProjectAsBCF(client, projects[0].project_id); +// `project` is an @ifc-lite/bcf BCFProject: topics, comments, viewpoints +// (cameras, selection, coloring, visibility) and snapshots as data URLs. +``` + +`fetchProjectAsBCF` pages the topics collection (`$top`/`$skip`), fetches each topic's comments and viewpoints concurrently, resolves viewpoint components (inline or via the `/selection`, `/coloring`, `/visibility` subresources), and downloads snapshots. Per-item failures degrade to entries in `warnings` instead of failing the pull. + +## Direct endpoint access + +```ts +await client.getVersions(); +await client.getCurrentUser(); +await client.getExtensions(projectId); +await client.getTopics(projectId, { filter: "topic_status eq 'Open'", top: 50 }); +await client.getComments(projectId, topicGuid); +await client.getViewpoint(projectId, topicGuid, viewpointGuid); +await client.getViewpointSnapshot(projectId, topicGuid, viewpointGuid); // Blob +await client.createTopic(projectId, { title: 'Clash at grid 3/C' }); +await client.createComment(projectId, topicGuid, { comment: 'Fixed in rev B' }); +``` + +Errors are `BcfApiError` (with `status`, `url` and `isAuthError`); token endpoint failures are `BcfAuthenticationError` (with the RFC 6749 `errorCode`). Refresh an expiring session with `refreshAccessToken({ tokenUrl, refreshToken })`. + +## Other auth flows + +Not every server offers the password grant. `requestClientCredentialsToken({ tokenUrl, clientId, clientSecret })` covers servers with OAuth application credentials (e.g. OpenProject), and a token obtained elsewhere (a server's own UI, an authorization-code flow you run yourself) plugs straight into `getAccessToken`. + +## License + +MPL-2.0 diff --git a/packages/bcf-api/package.json b/packages/bcf-api/package.json new file mode 100644 index 0000000000..1ef0599c6b --- /dev/null +++ b/packages/bcf-api/package.json @@ -0,0 +1,55 @@ +{ + "name": "@ifc-lite/bcf-api", + "version": "0.1.0", + "description": "BCF API (OpenCDE) REST client for connecting to BCF servers", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "pnpm exec tsc", + "typecheck": "node ../../scripts/typecheck-tests.mjs", + "dev": "pnpm exec tsc --watch", + "test": "vitest run" + }, + "dependencies": { + "@ifc-lite/bcf": "workspace:^" + }, + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.10" + }, + "license": "MPL-2.0", + "author": "Louis True", + "repository": { + "type": "git", + "url": "https://github.com/LTplus-AG/ifc-lite.git", + "directory": "packages/bcf-api" + }, + "homepage": "https://ifclite.dev/docs/", + "bugs": "https://github.com/LTplus-AG/ifc-lite/issues", + "keywords": [ + "ifc", + "bim", + "bcf", + "bcf-api", + "opencde", + "buildingsmart", + "collaboration", + "issue-tracking", + "aec" + ], + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md" + ] +} diff --git a/packages/bcf-api/src/auth.test.ts b/packages/bcf-api/src/auth.test.ts new file mode 100644 index 0000000000..4e176f10bc --- /dev/null +++ b/packages/bcf-api/src/auth.test.ts @@ -0,0 +1,230 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'vitest'; +import { + exchangeAuthorizationCode, + refreshAccessToken, + registerBcfClient, + requestClientCredentialsToken, + requestPasswordToken, +} from './auth.js'; +import { BcfAuthenticationError } from './errors.js'; +import type { FetchLike } from './types.js'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('requestPasswordToken', () => { + it('posts a form-encoded password grant and parses the token set', async () => { + let captured: { url: string; init?: RequestInit } | undefined; + const fetchFn: FetchLike = async (url, init) => { + captured = { url, init }; + return jsonResponse({ + access_token: 'at-1', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rt-1', + }); + }; + const token = await requestPasswordToken({ + tokenUrl: 'https://host/bcf/oauth2/token', + username: 'user@example.com', + password: 'p&ss=word', + fetchFn, + }); + expect(token).toEqual({ + access_token: 'at-1', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rt-1', + }); + expect(captured?.url).toBe('https://host/bcf/oauth2/token'); + expect(captured?.init?.method).toBe('POST'); + expect(new Headers(captured?.init?.headers).get('Content-Type')).toBe( + 'application/x-www-form-urlencoded', + ); + const form = new URLSearchParams(String(captured?.init?.body)); + expect(form.get('grant_type')).toBe('password'); + expect(form.get('username')).toBe('user@example.com'); + expect(form.get('password')).toBe('p&ss=word'); + expect(form.has('client_id')).toBe(false); + }); + + it('includes client credentials only when provided', async () => { + let body = ''; + const fetchFn: FetchLike = async (_url, init) => { + body = String(init?.body); + return jsonResponse({ access_token: 'at' }); + }; + await requestPasswordToken({ + tokenUrl: 'https://host/token', + username: 'u', + password: 'p', + clientId: 'cid', + clientSecret: 'cs', + fetchFn, + }); + const form = new URLSearchParams(body); + expect(form.get('client_id')).toBe('cid'); + expect(form.get('client_secret')).toBe('cs'); + }); + + it('throws BcfAuthenticationError with the RFC 6749 error fields', async () => { + // Exact error shape observed from a live BCF server's token endpoint. + const fetchFn: FetchLike = async () => + jsonResponse( + { error: 'invalid_request', error_description: 'username and password are required' }, + 400, + ); + const error = await requestPasswordToken({ + tokenUrl: 'https://host/token', + username: 'u', + password: 'p', + fetchFn, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(BcfAuthenticationError); + const authError = error as BcfAuthenticationError; + expect(authError.errorCode).toBe('invalid_request'); + expect(authError.message).toBe('username and password are required'); + expect(authError.status).toBe(400); + }); + + it('rejects a 200 response that carries no access_token', async () => { + const fetchFn: FetchLike = async () => jsonResponse({ token_type: 'Bearer' }); + const error = await requestPasswordToken({ + tokenUrl: 'https://host/token', + username: 'u', + password: 'p', + fetchFn, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(BcfAuthenticationError); + expect((error as Error).message).toBe('Token response carried no access_token'); + }); +}); + +describe('requestClientCredentialsToken', () => { + it('posts a client_credentials grant with the app id and secret', async () => { + let body = ''; + const fetchFn: FetchLike = async (_url, init) => { + body = String(init?.body); + return jsonResponse({ access_token: 'at-cc', expires_in: 7200 }); + }; + const token = await requestClientCredentialsToken({ + tokenUrl: 'https://host/token', + clientId: 'app-id', + clientSecret: 'app-secret', + fetchFn, + }); + const form = new URLSearchParams(body); + expect(form.get('grant_type')).toBe('client_credentials'); + expect(form.get('client_id')).toBe('app-id'); + expect(form.get('client_secret')).toBe('app-secret'); + expect(token.access_token).toBe('at-cc'); + expect(token.expires_in).toBe(7200); + }); +}); + +describe('exchangeAuthorizationCode', () => { + it('posts the code, redirect_uri, PKCE verifier, and client credentials', async () => { + let body = ''; + const fetchFn: FetchLike = async (_url, init) => { + body = String(init?.body); + return jsonResponse({ access_token: 'at-code', refresh_token: 'rt-code' }); + }; + const token = await exchangeAuthorizationCode({ + tokenUrl: 'https://host/token', + code: 'auth-code-1', + redirectUri: 'https://viewer.example/oauth/bcf/callback', + codeVerifier: 'pkce-verifier', + clientId: 'app-id', + clientSecret: 'app-secret', + fetchFn, + }); + const form = new URLSearchParams(body); + expect(form.get('grant_type')).toBe('authorization_code'); + expect(form.get('code')).toBe('auth-code-1'); + expect(form.get('redirect_uri')).toBe('https://viewer.example/oauth/bcf/callback'); + expect(form.get('code_verifier')).toBe('pkce-verifier'); + expect(form.get('client_id')).toBe('app-id'); + expect(form.get('client_secret')).toBe('app-secret'); + expect(token.access_token).toBe('at-code'); + }); + + it('omits the PKCE verifier when none was used', async () => { + let body = ''; + const fetchFn: FetchLike = async (_url, init) => { + body = String(init?.body); + return jsonResponse({ access_token: 'at' }); + }; + await exchangeAuthorizationCode({ + tokenUrl: 'https://host/token', + code: 'c', + redirectUri: 'https://viewer.example/cb', + clientId: 'app-id', + fetchFn, + }); + expect(new URLSearchParams(body).has('code_verifier')).toBe(false); + }); +}); + +describe('registerBcfClient', () => { + it('posts the JSON registration and returns the minted client', async () => { + let captured: { url: string; init?: RequestInit } | undefined; + const fetchFn: FetchLike = async (url, init) => { + captured = { url, init }; + return jsonResponse({ client_id: 'minted-id', client_secret: 'minted-secret' }); + }; + const client = await registerBcfClient({ + registrationUrl: 'https://host/bcf/oauth2/register', + clientName: 'IFClite viewer', + clientUrl: 'https://viewer.example', + redirectUrl: 'https://viewer.example/oauth/bcf/callback', + fetchFn, + }); + expect(client).toEqual({ client_id: 'minted-id', client_secret: 'minted-secret' }); + expect(new Headers(captured?.init?.headers).get('Content-Type')).toBe('application/json'); + expect(JSON.parse(String(captured?.init?.body))).toEqual({ + client_name: 'IFClite viewer', + client_url: 'https://viewer.example', + redirect_url: 'https://viewer.example/oauth/bcf/callback', + }); + }); + + it('surfaces registration rejections and rejects responses without a client_id', async () => { + const rejecting: FetchLike = async () => + jsonResponse({ message: 'registration disabled' }, 403); + await expect( + registerBcfClient({ registrationUrl: 'https://host/reg', clientName: 'x', fetchFn: rejecting }), + ).rejects.toThrow('registration disabled'); + const empty: FetchLike = async () => jsonResponse({}); + await expect( + registerBcfClient({ registrationUrl: 'https://host/reg', clientName: 'x', fetchFn: empty }), + ).rejects.toThrow('no client_id'); + }); +}); + +describe('refreshAccessToken', () => { + it('posts a refresh_token grant', async () => { + let body = ''; + const fetchFn: FetchLike = async (_url, init) => { + body = String(init?.body); + return jsonResponse({ access_token: 'at-2', refresh_token: 'rt-2' }); + }; + const token = await refreshAccessToken({ + tokenUrl: 'https://host/token', + refreshToken: 'rt-1', + fetchFn, + }); + const form = new URLSearchParams(body); + expect(form.get('grant_type')).toBe('refresh_token'); + expect(form.get('refresh_token')).toBe('rt-1'); + expect(token.access_token).toBe('at-2'); + expect(token.refresh_token).toBe('rt-2'); + }); +}); diff --git a/packages/bcf-api/src/auth.ts b/packages/bcf-api/src/auth.ts new file mode 100644 index 0000000000..43e70d7010 --- /dev/null +++ b/packages/bcf-api/src/auth.ts @@ -0,0 +1,226 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { BcfAuthenticationError, extractErrorDetail } from './errors.js'; +import type { BcfTokenResponse, FetchLike } from './types.js'; + +interface TokenRequestOptions { + /** OAuth2 token endpoint, from the server's `/auth` discovery document. */ + tokenUrl: string; + /** Public client id, when the server requires one. */ + clientId?: string; + /** Client secret; only for servers whose token endpoint demands it. */ + clientSecret?: string; + fetchFn?: FetchLike; +} + +export interface PasswordGrantOptions extends TokenRequestOptions { + username: string; + password: string; +} + +export interface RefreshGrantOptions extends TokenRequestOptions { + refreshToken: string; +} + +export interface ClientCredentialsGrantOptions extends TokenRequestOptions { + clientId: string; + clientSecret: string; +} + +export interface AuthorizationCodeGrantOptions extends TokenRequestOptions { + /** The `code` returned to the redirect URI by the authorization server. */ + code: string; + /** Must byte-match the `redirect_uri` sent on the authorization request. */ + redirectUri: string; + /** PKCE verifier when the authorization request carried a challenge. */ + codeVerifier?: string; +} + +/** Request body of the BCF API dynamic client registration endpoint. */ +export interface RegisterClientOptions { + /** The server's `oauth2_dynamic_client_reg_url` from `/auth` discovery. */ + registrationUrl: string; + clientName: string; + clientDescription?: string; + clientUrl?: string; + redirectUrl?: string; + fetchFn?: FetchLike; +} + +export interface RegisteredClient { + client_id: string; + client_secret?: string; +} + +function resolveFetch(fetchFn: FetchLike | undefined): FetchLike { + if (fetchFn) return fetchFn; + if (typeof fetch === 'function') return fetch; + throw new Error('No fetch implementation available; pass fetchFn explicitly.'); +} + +async function postTokenRequest( + options: TokenRequestOptions, + form: Record, +): Promise { + const fetchFn = resolveFetch(options.fetchFn); + const body = new URLSearchParams(form); + if (options.clientId) body.set('client_id', options.clientId); + if (options.clientSecret) body.set('client_secret', options.clientSecret); + + const response = await fetchFn(options.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + }); + + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + if (response.ok) { + throw new BcfAuthenticationError('Token endpoint returned a non-JSON response', { + status: response.status, + url: options.tokenUrl, + detail: error instanceof Error ? error.message : String(error), + }); + } + parsed = undefined; + } + + if (!response.ok) { + const record = + typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + const errorCode = typeof record.error === 'string' ? record.error : undefined; + const detail = extractErrorDetail(parsed); + throw new BcfAuthenticationError(detail ?? `Token request failed (HTTP ${response.status})`, { + status: response.status, + url: options.tokenUrl, + errorCode, + detail, + }); + } + + const record = + typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + const accessToken = record.access_token; + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new BcfAuthenticationError('Token response carried no access_token', { + status: response.status, + url: options.tokenUrl, + }); + } + return { + access_token: accessToken, + token_type: typeof record.token_type === 'string' ? record.token_type : undefined, + expires_in: typeof record.expires_in === 'number' ? record.expires_in : undefined, + refresh_token: typeof record.refresh_token === 'string' ? record.refresh_token : undefined, + }; +} + +/** + * OAuth2 resource-owner password grant (RFC 6749 §4.3), one of the flows the + * BCF API auth discovery document can advertise + * (`resource_owner_password_credentials_grant`). The password is sent to the + * token endpoint once and never stored; persist only the returned tokens. + */ +export function requestPasswordToken(options: PasswordGrantOptions): Promise { + return postTokenRequest(options, { + grant_type: 'password', + username: options.username, + password: options.password, + }); +} + +/** OAuth2 refresh-token grant (RFC 6749 §6). */ +export function refreshAccessToken(options: RefreshGrantOptions): Promise { + return postTokenRequest(options, { + grant_type: 'refresh_token', + refresh_token: options.refreshToken, + }); +} + +/** + * OAuth2 client-credentials grant (RFC 6749 §4.4). Some BCF servers (e.g. + * OpenProject) advertise this for machine-style access using an OAuth + * application's id and secret instead of a user login. + */ +export function requestClientCredentialsToken( + options: ClientCredentialsGrantOptions, +): Promise { + // clientId/clientSecret ride the shared TokenRequestOptions fields, which + // postTokenRequest already encodes into the form body. + return postTokenRequest(options, { grant_type: 'client_credentials' }); +} + +/** + * OAuth2 authorization-code exchange (RFC 6749 §4.1.3), the second half of + * the browser sign-in every BCF vendor advertises + * (`authorization_code_grant`). Sends the PKCE verifier when one is given; + * servers that never saw a challenge ignore it. + */ +export function exchangeAuthorizationCode( + options: AuthorizationCodeGrantOptions, +): Promise { + const form: Record = { + grant_type: 'authorization_code', + code: options.code, + redirect_uri: options.redirectUri, + }; + if (options.codeVerifier) form.code_verifier = options.codeVerifier; + return postTokenRequest(options, form); +} + +/** + * BCF API dynamic client registration: exchange an app name and redirect + * URL for a client id (and usually a secret) on servers that advertise + * `oauth2_dynamic_client_reg_url`, so users never have to pre-register an + * OAuth application by hand. + */ +export async function registerBcfClient(options: RegisterClientOptions): Promise { + const fetchFn = resolveFetch(options.fetchFn); + const response = await fetchFn(options.registrationUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_name: options.clientName, + client_description: options.clientDescription, + client_url: options.clientUrl, + redirect_url: options.redirectUrl, + }), + }); + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + throw new BcfAuthenticationError('Client registration returned a non-JSON response', { + status: response.status, + url: options.registrationUrl, + detail: error instanceof Error ? error.message : String(error), + }); + } + const record = + typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + if (!response.ok) { + const detail = extractErrorDetail(parsed); + throw new BcfAuthenticationError(detail ?? `Client registration failed (HTTP ${response.status})`, { + status: response.status, + url: options.registrationUrl, + detail, + }); + } + if (typeof record.client_id !== 'string' || record.client_id.length === 0) { + throw new BcfAuthenticationError('Client registration response carried no client_id', { + status: response.status, + url: options.registrationUrl, + }); + } + return { + client_id: record.client_id, + client_secret: typeof record.client_secret === 'string' ? record.client_secret : undefined, + }; +} diff --git a/packages/bcf-api/src/client.test.ts b/packages/bcf-api/src/client.test.ts new file mode 100644 index 0000000000..fe4a374a56 --- /dev/null +++ b/packages/bcf-api/src/client.test.ts @@ -0,0 +1,153 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'vitest'; +import { BcfApiClient, normalizeBcfBaseUrl } from './client.js'; +import { BcfApiError } from './errors.js'; +import type { FetchLike } from './types.js'; + +interface RecordedRequest { + url: string; + init?: RequestInit; +} + +function mockFetch( + handler: (url: string, init?: RequestInit) => Response | Promise, +): { fetchFn: FetchLike; requests: RecordedRequest[] } { + const requests: RecordedRequest[] = []; + const fetchFn: FetchLike = async (url, init) => { + requests.push({ url, init }); + return handler(url, init); + }; + return { fetchFn, requests }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('normalizeBcfBaseUrl', () => { + it('strips trailing slashes and pasted version segments', () => { + expect(normalizeBcfBaseUrl('https://host/bcf')).toBe('https://host/bcf'); + expect(normalizeBcfBaseUrl('https://host/bcf/')).toBe('https://host/bcf'); + expect(normalizeBcfBaseUrl('https://host/bcf/2.1')).toBe('https://host/bcf'); + expect(normalizeBcfBaseUrl('https://host/bcf/3.0/')).toBe('https://host/bcf'); + expect(normalizeBcfBaseUrl(' https://host/bcf ')).toBe('https://host/bcf'); + }); + + it('keeps URLs whose last segment is not a version number', () => { + expect(normalizeBcfBaseUrl('https://host/api/v1')).toBe('https://host/api/v1'); + }); +}); + +describe('BcfApiClient URL construction', () => { + it('serves /versions beside the version segment, everything else under it', async () => { + const { fetchFn, requests } = mockFetch((url) => + url.endsWith('/versions') + ? jsonResponse({ versions: [{ version_id: '2.1' }] }) + : jsonResponse([]), + ); + const client = new BcfApiClient({ baseUrl: 'https://host/bcf/', fetchFn }); + const versions = await client.getVersions(); + expect(versions).toEqual([{ version_id: '2.1' }]); + await client.getProjects(); + expect(requests[0].url).toBe('https://host/bcf/versions'); + expect(requests[1].url).toBe('https://host/bcf/2.1/projects'); + }); + + it('encodes OData topic query options as $-prefixed params', async () => { + const { fetchFn, requests } = mockFetch(() => jsonResponse([])); + const client = new BcfApiClient({ baseUrl: 'https://host/bcf', fetchFn }); + await client.getTopics('p1', { top: 50, skip: 100, filter: "topic_status eq 'Open'" }); + const url = new URL(requests[0].url); + expect(url.pathname).toBe('/bcf/2.1/projects/p1/topics'); + expect(url.searchParams.get('$top')).toBe('50'); + expect(url.searchParams.get('$skip')).toBe('100'); + expect(url.searchParams.get('$filter')).toBe("topic_status eq 'Open'"); + expect(url.searchParams.has('$orderby')).toBe(false); + }); + + it('URL-encodes project and topic identifiers', async () => { + const { fetchFn, requests } = mockFetch(() => jsonResponse([])); + const client = new BcfApiClient({ baseUrl: 'https://host/bcf', fetchFn }); + await client.getComments('a b', 'c/d'); + expect(requests[0].url).toBe('https://host/bcf/2.1/projects/a%20b/topics/c%2Fd/comments'); + }); +}); + +describe('BcfApiClient auth handling', () => { + it('attaches the Bearer token from the async provider', async () => { + const { fetchFn, requests } = mockFetch(() => jsonResponse([])); + const client = new BcfApiClient({ + baseUrl: 'https://host/bcf', + fetchFn, + getAccessToken: async () => 'tok-123', + }); + await client.getProjects(); + expect(new Headers(requests[0].init?.headers).get('Authorization')).toBe('Bearer tok-123'); + }); + + it('sends no Authorization header when the provider yields undefined', async () => { + const { fetchFn, requests } = mockFetch(() => jsonResponse([])); + const client = new BcfApiClient({ + baseUrl: 'https://host/bcf', + fetchFn, + getAccessToken: () => undefined, + }); + await client.getVersions(); + expect(new Headers(requests[0].init?.headers).get('Authorization')).toBeNull(); + }); + + it('surfaces 401 as an isAuthError BcfApiError with the server message', async () => { + const { fetchFn } = mockFetch(() => jsonResponse({ message: 'Not authenticated' }, 401)); + const client = new BcfApiClient({ baseUrl: 'https://host/bcf', fetchFn }); + const error = await client.getProjects().catch((e: unknown) => e); + expect(error).toBeInstanceOf(BcfApiError); + const apiError = error as BcfApiError; + expect(apiError.status).toBe(401); + expect(apiError.isAuthError).toBe(true); + expect(apiError.message).toBe('Not authenticated'); + }); + + it('reports non-JSON error bodies by status line', async () => { + const { fetchFn } = mockFetch( + () => new Response('gateway timeout', { status: 504 }), + ); + const client = new BcfApiClient({ baseUrl: 'https://host/bcf', fetchFn }); + const error = await client.getProjects().catch((e: unknown) => e); + expect(error).toBeInstanceOf(BcfApiError); + expect((error as BcfApiError).message).toBe('BCF request failed (HTTP 504)'); + }); +}); + +describe('BcfApiClient bodies and binary responses', () => { + it('POSTs comments as JSON with Content-Type', async () => { + const { fetchFn, requests } = mockFetch(() => + jsonResponse({ guid: 'c1', comment: 'hello' }), + ); + const client = new BcfApiClient({ baseUrl: 'https://host/bcf', fetchFn }); + const created = await client.createComment('p1', 't1', { comment: 'hello' }); + expect(created.guid).toBe('c1'); + expect(requests[0].init?.method).toBe('POST'); + expect(new Headers(requests[0].init?.headers).get('Content-Type')).toBe('application/json'); + expect(JSON.parse(String(requests[0].init?.body))).toEqual({ comment: 'hello' }); + }); + + it('returns the snapshot as a Blob with its content type', async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { fetchFn, requests } = mockFetch( + () => new Response(png, { status: 200, headers: { 'Content-Type': 'image/png' } }), + ); + const client = new BcfApiClient({ baseUrl: 'https://host/bcf', fetchFn }); + const blob = await client.getViewpointSnapshot('p1', 't1', 'v1'); + expect(requests[0].url).toBe( + 'https://host/bcf/2.1/projects/p1/topics/t1/viewpoints/v1/snapshot', + ); + expect(blob.type).toBe('image/png'); + expect(blob.size).toBe(4); + }); +}); diff --git a/packages/bcf-api/src/client.ts b/packages/bcf-api/src/client.ts new file mode 100644 index 0000000000..5ed8bea660 --- /dev/null +++ b/packages/bcf-api/src/client.ts @@ -0,0 +1,304 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { BcfApiError, extractErrorDetail } from './errors.js'; +import type { + BcfApiVersion, + BcfAuthInfo, + BcfColoringResponse, + BcfCommentDto, + BcfCommentWriteDto, + BcfCurrentUser, + BcfExtensionsDto, + BcfProjectDto, + BcfSelectionResponse, + BcfTokenProvider, + BcfTopicDto, + BcfTopicWriteDto, + BcfViewpointDto, + BcfVisibilityResponse, + FetchLike, +} from './types.js'; + +export interface BcfApiClientOptions { + /** + * Server base URL up to but excluding the version segment, e.g. + * `https://example.com/bcf`. Run user input through + * {@link normalizeBcfBaseUrl} first to tolerate trailing slashes and + * pasted version suffixes. + */ + baseUrl: string; + /** BCF API version segment; defaults to '2.1'. */ + version?: string; + /** Supplies the Bearer token per request; omit for anonymous servers. */ + getAccessToken?: BcfTokenProvider; + /** Injectable fetch, for tests and non-browser hosts. */ + fetchFn?: FetchLike; +} + +/** OData-style query options of the BCF API topics collection. */ +export interface TopicQueryOptions { + filter?: string; + orderby?: string; + top?: number; + skip?: number; +} + +/** + * Strip whitespace, trailing slashes, and an accidentally pasted version + * segment ('/2.1', '/3.0') from a user-entered BCF server URL, so + * `https://host/bcf/2.1/` and `https://host/bcf` configure the same client. + */ +export function normalizeBcfBaseUrl(input: string): string { + let url = input.trim(); + while (url.endsWith('/')) url = url.slice(0, -1); + const versionSuffix = /\/(\d+\.\d+)$/.exec(url); + if (versionSuffix) url = url.slice(0, -versionSuffix[0].length); + return url; +} + +interface RequestOptions { + method?: string; + query?: Record; + body?: unknown; +} + +/** + * Typed client for the buildingSMART BCF API (OpenCDE) REST services. + * Implements the BCF API 2.1 routes; the `version` option exists because + * 3.0 servers share these shapes and paths for everything the client uses. + */ +export class BcfApiClient { + private readonly baseUrl: string; + private readonly version: string; + private readonly getAccessToken?: BcfTokenProvider; + private readonly fetchFn: FetchLike; + + constructor(options: BcfApiClientOptions) { + this.baseUrl = normalizeBcfBaseUrl(options.baseUrl); + this.version = options.version ?? '2.1'; + this.getAccessToken = options.getAccessToken; + if (options.fetchFn) { + this.fetchFn = options.fetchFn; + } else if (typeof fetch === 'function') { + // Browsers throw "Illegal invocation" when fetch is called unbound. + this.fetchFn = (input, init) => fetch(input, init); + } else { + throw new Error('No fetch implementation available; pass fetchFn explicitly.'); + } + } + + private buildUrl(path: string, query?: RequestOptions['query']): string { + // `/versions` sits beside the version segment, not under it (BCF API §2.1). + const prefix = path === '/versions' ? this.baseUrl : `${this.baseUrl}/${this.version}`; + const url = new URL(`${prefix}${path}`); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + } + return url.toString(); + } + + private async send(path: string, options: RequestOptions): Promise { + const url = this.buildUrl(path, options.query); + const headers: Record = { Accept: 'application/json' }; + const token = await this.getAccessToken?.(); + if (token) headers.Authorization = `Bearer ${token}`; + let body: string | undefined; + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); + } + const response = await this.fetchFn(url, { + method: options.method ?? 'GET', + headers, + body, + }); + if (!response.ok) { + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + // Non-JSON error body; the status line is all we can report. + parsed = undefined; + } + const detail = extractErrorDetail(parsed); + throw new BcfApiError(detail ?? `BCF request failed (HTTP ${response.status})`, { + status: response.status, + url, + detail, + }); + } + return response; + } + + private async requestJson(path: string, options: RequestOptions = {}): Promise { + const response = await this.send(path, options); + return (await response.json()) as T; + } + + // -- Discovery & identity -------------------------------------------------- + + async getVersions(): Promise { + const result = await this.requestJson<{ versions?: BcfApiVersion[] }>('/versions'); + return result.versions ?? []; + } + + getAuthInfo(): Promise { + return this.requestJson('/auth'); + } + + getCurrentUser(): Promise { + return this.requestJson('/current-user'); + } + + // -- Projects -------------------------------------------------------------- + + getProjects(): Promise { + return this.requestJson('/projects'); + } + + getProject(projectId: string): Promise { + return this.requestJson(`/projects/${encodeURIComponent(projectId)}`); + } + + getExtensions(projectId: string): Promise { + return this.requestJson( + `/projects/${encodeURIComponent(projectId)}/extensions`, + ); + } + + // -- Topics ---------------------------------------------------------------- + + getTopics(projectId: string, options: TopicQueryOptions = {}): Promise { + return this.requestJson(`/projects/${encodeURIComponent(projectId)}/topics`, { + query: { + $filter: options.filter, + $orderby: options.orderby, + $top: options.top, + $skip: options.skip, + }, + }); + } + + getTopic(projectId: string, topicGuid: string): Promise { + return this.requestJson(this.topicPath(projectId, topicGuid)); + } + + createTopic(projectId: string, topic: BcfTopicWriteDto): Promise { + return this.requestJson(`/projects/${encodeURIComponent(projectId)}/topics`, { + method: 'POST', + body: topic, + }); + } + + updateTopic( + projectId: string, + topicGuid: string, + topic: BcfTopicWriteDto, + ): Promise { + return this.requestJson(this.topicPath(projectId, topicGuid), { + method: 'PUT', + body: topic, + }); + } + + // -- Comments -------------------------------------------------------------- + + getComments(projectId: string, topicGuid: string): Promise { + return this.requestJson(`${this.topicPath(projectId, topicGuid)}/comments`); + } + + createComment( + projectId: string, + topicGuid: string, + comment: BcfCommentWriteDto, + ): Promise { + return this.requestJson(`${this.topicPath(projectId, topicGuid)}/comments`, { + method: 'POST', + body: comment, + }); + } + + // -- Viewpoints ------------------------------------------------------------ + + getViewpoints(projectId: string, topicGuid: string): Promise { + return this.requestJson( + `${this.topicPath(projectId, topicGuid)}/viewpoints`, + ); + } + + getViewpoint( + projectId: string, + topicGuid: string, + viewpointGuid: string, + ): Promise { + return this.requestJson( + this.viewpointPath(projectId, topicGuid, viewpointGuid), + ); + } + + createViewpoint( + projectId: string, + topicGuid: string, + viewpoint: BcfViewpointDto, + ): Promise { + return this.requestJson( + `${this.topicPath(projectId, topicGuid)}/viewpoints`, + { method: 'POST', body: viewpoint }, + ); + } + + getViewpointSelection( + projectId: string, + topicGuid: string, + viewpointGuid: string, + ): Promise { + return this.requestJson( + `${this.viewpointPath(projectId, topicGuid, viewpointGuid)}/selection`, + ); + } + + getViewpointColoring( + projectId: string, + topicGuid: string, + viewpointGuid: string, + ): Promise { + return this.requestJson( + `${this.viewpointPath(projectId, topicGuid, viewpointGuid)}/coloring`, + ); + } + + getViewpointVisibility( + projectId: string, + topicGuid: string, + viewpointGuid: string, + ): Promise { + return this.requestJson( + `${this.viewpointPath(projectId, topicGuid, viewpointGuid)}/visibility`, + ); + } + + /** Snapshot image (PNG/JPEG) of a viewpoint, as served by the server. */ + async getViewpointSnapshot( + projectId: string, + topicGuid: string, + viewpointGuid: string, + ): Promise { + const response = await this.send( + `${this.viewpointPath(projectId, topicGuid, viewpointGuid)}/snapshot`, + {}, + ); + return response.blob(); + } + + private topicPath(projectId: string, topicGuid: string): string { + return `/projects/${encodeURIComponent(projectId)}/topics/${encodeURIComponent(topicGuid)}`; + } + + private viewpointPath(projectId: string, topicGuid: string, viewpointGuid: string): string { + return `${this.topicPath(projectId, topicGuid)}/viewpoints/${encodeURIComponent(viewpointGuid)}`; + } +} diff --git a/packages/bcf-api/src/errors.ts b/packages/bcf-api/src/errors.ts new file mode 100644 index 0000000000..42a111c88a --- /dev/null +++ b/packages/bcf-api/src/errors.ts @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** HTTP-level failure from a BCF server (non-2xx response). */ +export class BcfApiError extends Error { + /** HTTP status code; 0 when the request never produced a response. */ + readonly status: number; + /** Request URL with any query string, for diagnostics. */ + readonly url: string; + /** Server-provided error detail, when the body carried one. */ + readonly detail?: string; + + constructor(message: string, options: { status: number; url: string; detail?: string }) { + super(message); + this.name = 'BcfApiError'; + this.status = options.status; + this.url = options.url; + this.detail = options.detail; + } + + /** True when the server rejected the credentials (sign in again). */ + get isAuthError(): boolean { + return this.status === 401; + } +} + +/** OAuth2 token endpoint failure (RFC 6749 error responses). */ +export class BcfAuthenticationError extends BcfApiError { + /** RFC 6749 error code, e.g. 'invalid_grant' or 'invalid_request'. */ + readonly errorCode?: string; + + constructor( + message: string, + options: { status: number; url: string; errorCode?: string; detail?: string }, + ) { + super(message, options); + this.name = 'BcfAuthenticationError'; + this.errorCode = options.errorCode; + } +} + +/** + * Extract a human-readable message from a BCF server error body. Servers + * vary: BCF API prescribes `{message}`, OAuth2 uses `{error, error_description}`, + * FastAPI emits `{detail}`. + */ +export function extractErrorDetail(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) return undefined; + const record = body as Record; + for (const key of ['message', 'error_description', 'detail', 'error']) { + const value = record[key]; + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} diff --git a/packages/bcf-api/src/index.ts b/packages/bcf-api/src/index.ts new file mode 100644 index 0000000000..04fbcced76 --- /dev/null +++ b/packages/bcf-api/src/index.ts @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * @ifc-lite/bcf-api — REST client for buildingSMART BCF API (OpenCDE) + * servers. Connects to a BCF server, authenticates via OAuth2, and pulls + * projects, topics, comments and viewpoints into the `@ifc-lite/bcf` + * in-memory model. + */ + +export { BcfApiClient, normalizeBcfBaseUrl } from './client.js'; +export type { BcfApiClientOptions, TopicQueryOptions } from './client.js'; + +export { + exchangeAuthorizationCode, + refreshAccessToken, + registerBcfClient, + requestClientCredentialsToken, + requestPasswordToken, +} from './auth.js'; +export type { + AuthorizationCodeGrantOptions, + ClientCredentialsGrantOptions, + PasswordGrantOptions, + RefreshGrantOptions, + RegisterClientOptions, + RegisteredClient, +} from './auth.js'; + +export { BcfApiError, BcfAuthenticationError } from './errors.js'; + +export { fetchProjectAsBCF } from './sync.js'; +export type { BcfProjectFetchResult, BcfSyncProgress, FetchProjectOptions } from './sync.js'; + +export type { + BcfApiVersion, + BcfAuthInfo, + BcfClippingPlaneDto, + BcfColoringDto, + BcfColoringResponse, + BcfCommentDto, + BcfCommentWriteDto, + BcfComponentDto, + BcfComponentsDto, + BcfCurrentUser, + BcfExtensionsDto, + BcfLineDto, + BcfOrthogonalCameraDto, + BcfPerspectiveCameraDto, + BcfPointDto, + BcfProjectDto, + BcfSelectionResponse, + BcfTokenProvider, + BcfTokenResponse, + BcfTopicDto, + BcfTopicWriteDto, + BcfViewpointDto, + BcfViewSetupHintsDto, + BcfVisibilityDto, + BcfVisibilityResponse, + FetchLike, +} from './types.js'; diff --git a/packages/bcf-api/src/mapping.test.ts b/packages/bcf-api/src/mapping.test.ts new file mode 100644 index 0000000000..5855e1c90b --- /dev/null +++ b/packages/bcf-api/src/mapping.test.ts @@ -0,0 +1,307 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'vitest'; +import { + commentFromApi, + componentsFromApi, + extensionsFromApi, + normalizeArgbColor, + topicFromApi, + viewpointFromApi, +} from './mapping.js'; +import type { BcfTopicDto, BcfViewpointDto } from './types.js'; + +describe('topicFromApi', () => { + it('maps every snake_case field onto the BCF-XML model shape', () => { + const dto: BcfTopicDto = { + guid: 'aaaa-bbbb', + topic_type: 'Clash', + topic_status: 'Open', + title: 'Pipe collides with beam', + priority: 'High', + index: 3, + labels: ['MEP', 'Structural'], + creation_date: '2026-08-01T10:00:00Z', + creation_author: 'alice@example.com', + modified_date: '2026-08-02T11:00:00Z', + modified_author: 'bob@example.com', + assigned_to: 'carol@example.com', + stage: 'Design', + description: 'Reroute the pipe', + due_date: '2026-09-01T00:00:00Z', + bim_snippet: { + snippet_type: 'clash', + is_external: true, + reference: 'https://example.com/clash.json', + reference_schema: 'https://example.com/schema.json', + }, + }; + const topic = topicFromApi(dto); + expect(topic).toEqual({ + guid: 'aaaa-bbbb', + title: 'Pipe collides with beam', + description: 'Reroute the pipe', + topicType: 'Clash', + topicStatus: 'Open', + priority: 'High', + index: 3, + creationDate: '2026-08-01T10:00:00Z', + creationAuthor: 'alice@example.com', + modifiedDate: '2026-08-02T11:00:00Z', + modifiedAuthor: 'bob@example.com', + dueDate: '2026-09-01T00:00:00Z', + assignedTo: 'carol@example.com', + stage: 'Design', + labels: ['MEP', 'Structural'], + bimSnippet: { + snippetType: 'clash', + isExternal: true, + reference: 'https://example.com/clash.json', + referenceSchema: 'https://example.com/schema.json', + }, + comments: [], + viewpoints: [], + }); + }); + + it('defaults a missing title rather than producing an untitled crash', () => { + const topic = topicFromApi({ guid: 'g1' }); + expect(topic.title).toBe('Untitled topic'); + expect(topic.labels).toBeUndefined(); + expect(topic.bimSnippet).toBeUndefined(); + }); + + it('normalizes explicit nulls to undefined (servers that null empty optionals)', () => { + // Shape observed live: a spec-strict server sends null, not omission. + const topic = topicFromApi({ + guid: 'g2', + title: 'Topic', + creation_date: '2026-08-25T07:12:27.467+00:00', + creation_author: 'alice@example.com', + labels: [], + topic_type: 'Issue', + topic_status: 'Resolved', + priority: null, + stage: null, + description: null, + assigned_to: null, + due_date: null, + index: 0, + modified_date: null, + modified_author: null, + bim_snippet: null, + }); + expect(topic.priority).toBeUndefined(); + expect(topic.stage).toBeUndefined(); + expect(topic.description).toBeUndefined(); + expect(topic.assignedTo).toBeUndefined(); + expect(topic.dueDate).toBeUndefined(); + expect(topic.modifiedDate).toBeUndefined(); + expect(topic.modifiedAuthor).toBeUndefined(); + expect(topic.bimSnippet).toBeUndefined(); + // No property of the mapped topic may hold null. + expect(Object.values(topic).some((v) => v === null)).toBe(false); + }); +}); + +describe('commentFromApi', () => { + it('normalizes explicit nulls to undefined', () => { + const comment = commentFromApi({ + guid: 'c9', + date: '2026-08-25T09:41:48.210+00:00', + author: 'admin@example.com', + comment: 'hi', + viewpoint_guid: null, + modified_date: null, + modified_author: null, + }); + expect(comment.viewpointGuid).toBeUndefined(); + expect(comment.modifiedDate).toBeUndefined(); + expect(Object.values(comment).some((v) => v === null)).toBe(false); + }); + + it('maps viewpoint binding and modification fields', () => { + expect( + commentFromApi({ + guid: 'c1', + date: '2026-08-01T10:00:00Z', + author: 'alice@example.com', + comment: 'Please fix', + viewpoint_guid: 'v1', + modified_date: '2026-08-02T10:00:00Z', + modified_author: 'bob@example.com', + }), + ).toEqual({ + guid: 'c1', + date: '2026-08-01T10:00:00Z', + author: 'alice@example.com', + comment: 'Please fix', + viewpointGuid: 'v1', + modifiedDate: '2026-08-02T10:00:00Z', + modifiedAuthor: 'bob@example.com', + }); + }); +}); + +describe('viewpointFromApi', () => { + const perspectiveDto: BcfViewpointDto = { + guid: 'v1', + perspective_camera: { + camera_view_point: { x: 1, y: 2, z: 3 }, + camera_direction: { x: 0, y: 1, z: 0 }, + camera_up_vector: { x: 0, y: 0, z: 1 }, + field_of_view: 60, + }, + lines: [{ start_point: { x: 0, y: 0, z: 0 }, end_point: { x: 1, y: 1, z: 1 } }], + clipping_planes: [{ location: { x: 0, y: 0, z: 2 }, direction: { x: 0, y: 0, z: 1 } }], + }; + + it('maps cameras, lines and clipping planes to camelCase', () => { + const viewpoint = viewpointFromApi(perspectiveDto); + expect(viewpoint.guid).toBe('v1'); + expect(viewpoint.perspectiveCamera).toEqual({ + cameraViewPoint: { x: 1, y: 2, z: 3 }, + cameraDirection: { x: 0, y: 1, z: 0 }, + cameraUpVector: { x: 0, y: 0, z: 1 }, + fieldOfView: 60, + aspectRatio: undefined, + }); + expect(viewpoint.orthogonalCamera).toBeUndefined(); + expect(viewpoint.lines).toEqual([ + { startPoint: { x: 0, y: 0, z: 0 }, endPoint: { x: 1, y: 1, z: 1 } }, + ]); + expect(viewpoint.clippingPlanes).toEqual([ + { location: { x: 0, y: 0, z: 2 }, direction: { x: 0, y: 0, z: 1 } }, + ]); + }); + + it('drops a camera with non-finite or missing fields instead of fabricating one', () => { + const broken = viewpointFromApi({ + guid: 'v2', + perspective_camera: { + camera_view_point: { x: Number.NaN, y: 0, z: 0 }, + camera_direction: { x: 0, y: 1, z: 0 }, + camera_up_vector: { x: 0, y: 0, z: 1 }, + field_of_view: 60, + }, + }); + expect(broken.perspectiveCamera).toBeUndefined(); + }); + + it('prefers explicitly fetched components over inline ones', () => { + const viewpoint = viewpointFromApi( + { ...perspectiveDto, components: { selection: [{ ifc_guid: 'inline_guid_000000000A' }] } }, + { selection: [{ ifc_guid: 'fetched_guid_00000000A' }] }, + ); + expect(viewpoint.components?.selection?.[0].ifcGuid).toBe('fetched_guid_00000000A'); + }); + + it('maps orthogonal cameras with view_to_world_scale', () => { + const viewpoint = viewpointFromApi({ + guid: 'v3', + orthogonal_camera: { + camera_view_point: { x: 0, y: 0, z: 10 }, + camera_direction: { x: 0, y: 0, z: -1 }, + camera_up_vector: { x: 0, y: 1, z: 0 }, + view_to_world_scale: 5, + }, + }); + expect(viewpoint.orthogonalCamera?.viewToWorldScale).toBe(5); + }); +}); + +describe('componentsFromApi', () => { + it('assembles selection, visibility and coloring with IFC GlobalIds passed through', () => { + const components = componentsFromApi({ + selection: [{ ifc_guid: '0Bv2mAq6X4qfmHc0_Vw$aA', originating_system: 'ifc-lite' }], + visibility: { + default_visibility: false, + exceptions: [{ ifc_guid: '1Cv2mAq6X4qfmHc0_Vw$aB' }], + view_setup_hints: { spaces_visible: true, openings_visible: false }, + }, + coloring: [{ color: 'ff0000', components: [{ ifc_guid: '2Dv2mAq6X4qfmHc0_Vw$aC' }] }], + }); + expect(components).toEqual({ + selection: [ + { + ifcGuid: '0Bv2mAq6X4qfmHc0_Vw$aA', + originatingSystem: 'ifc-lite', + authoringToolId: undefined, + }, + ], + visibility: { + defaultVisibility: false, + exceptions: [ + { ifcGuid: '1Cv2mAq6X4qfmHc0_Vw$aB', originatingSystem: undefined, authoringToolId: undefined }, + ], + viewSetupHints: { + spacesVisible: true, + spaceBoundariesVisible: undefined, + openingsVisible: false, + }, + }, + coloring: [ + { + color: 'FFFF0000', + components: [ + { ifcGuid: '2Dv2mAq6X4qfmHc0_Vw$aC', originatingSystem: undefined, authoringToolId: undefined }, + ], + }, + ], + }); + }); + + it('returns undefined when every channel is empty', () => { + expect(componentsFromApi({})).toBeUndefined(); + expect(componentsFromApi({ selection: [], coloring: [] })).toBeUndefined(); + }); + + it('reads an omitted default_visibility as false (BCF API 2.1 wire default)', () => { + // Servers encode isolation viewpoints as exceptions-only visibility with + // default_visibility omitted; the spec default is false, so this must + // isolate the listed components — `?? true` would hide them instead. + const components = componentsFromApi({ + visibility: { exceptions: [{ ifc_guid: '1Cv2mAq6X4qfmHc0_Vw$aB' }] }, + }); + expect(components?.visibility?.defaultVisibility).toBe(false); + }); +}); + +describe('normalizeArgbColor', () => { + it('expands 6-hex RGB to ARGB with full alpha', () => { + expect(normalizeArgbColor('ff0000')).toBe('FFFF0000'); + expect(normalizeArgbColor('#00ff00')).toBe('FF00FF00'); + }); + it('keeps 8-hex ARGB unchanged apart from casing', () => { + expect(normalizeArgbColor('80ff0000')).toBe('80FF0000'); + }); +}); + +describe('extensionsFromApi', () => { + it('maps the extension vocabularies', () => { + expect( + extensionsFromApi({ + topic_type: ['Clash'], + topic_status: ['Open', 'Closed'], + priority: ['High'], + topic_label: ['MEP'], + user_id_type: ['alice@example.com'], + stage: ['Design'], + }), + ).toEqual({ + topicTypes: ['Clash'], + topicStatuses: ['Open', 'Closed'], + priorities: ['High'], + topicLabels: ['MEP'], + users: ['alice@example.com'], + stages: ['Design'], + }); + }); + + it('returns undefined for an empty extensions document', () => { + expect(extensionsFromApi({})).toBeUndefined(); + expect(extensionsFromApi({ topic_type: [] })).toBeUndefined(); + }); +}); diff --git a/packages/bcf-api/src/mapping.ts b/packages/bcf-api/src/mapping.ts new file mode 100644 index 0000000000..02cd521500 --- /dev/null +++ b/packages/bcf-api/src/mapping.ts @@ -0,0 +1,255 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Converts BCF API wire DTOs (snake_case JSON) into the `@ifc-lite/bcf` + * in-memory model (the shapes `readBCF` produces), so server-fetched topics + * flow through the same viewer code paths as imported .bcfzip files. + */ + +import type { + BCFColoring, + BCFComment, + BCFComponent, + BCFComponents, + BCFExtensions, + BCFOrthogonalCamera, + BCFPerspectiveCamera, + BCFPoint, + BCFTopic, + BCFViewpoint, + BCFVisibility, +} from '@ifc-lite/bcf'; +import type { + BcfColoringDto, + BcfCommentDto, + BcfComponentDto, + BcfComponentsDto, + BcfExtensionsDto, + BcfOrthogonalCameraDto, + BcfPerspectiveCameraDto, + BcfPointDto, + BcfTopicDto, + BcfViewpointDto, + BcfVisibilityDto, +} from './types.js'; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +/** + * Some servers send explicit `null` for empty optional fields instead of + * omitting them; the in-memory model uses `undefined` throughout, so nulls + * are normalized at this boundary. + */ +function orUndefined(value: T | null | undefined): T | undefined { + return value ?? undefined; +} + +function pointFromDto(dto: BcfPointDto | null | undefined): BCFPoint | undefined { + if (!dto) return undefined; + if (!isFiniteNumber(dto.x) || !isFiniteNumber(dto.y) || !isFiniteNumber(dto.z)) { + return undefined; + } + return { x: dto.x, y: dto.y, z: dto.z }; +} + +function perspectiveCameraFromDto( + dto: BcfPerspectiveCameraDto | null | undefined, +): BCFPerspectiveCamera | undefined { + if (!dto) return undefined; + const viewPoint = pointFromDto(dto.camera_view_point); + const direction = pointFromDto(dto.camera_direction); + const up = pointFromDto(dto.camera_up_vector); + if (!viewPoint || !direction || !up || !isFiniteNumber(dto.field_of_view)) return undefined; + return { + cameraViewPoint: viewPoint, + cameraDirection: direction, + cameraUpVector: up, + fieldOfView: dto.field_of_view, + aspectRatio: isFiniteNumber(dto.aspect_ratio) ? dto.aspect_ratio : undefined, + }; +} + +function orthogonalCameraFromDto( + dto: BcfOrthogonalCameraDto | null | undefined, +): BCFOrthogonalCamera | undefined { + if (!dto) return undefined; + const viewPoint = pointFromDto(dto.camera_view_point); + const direction = pointFromDto(dto.camera_direction); + const up = pointFromDto(dto.camera_up_vector); + if (!viewPoint || !direction || !up || !isFiniteNumber(dto.view_to_world_scale)) { + return undefined; + } + return { + cameraViewPoint: viewPoint, + cameraDirection: direction, + cameraUpVector: up, + viewToWorldScale: dto.view_to_world_scale, + aspectRatio: isFiniteNumber(dto.aspect_ratio) ? dto.aspect_ratio : undefined, + }; +} + +function componentFromDto(dto: BcfComponentDto): BCFComponent { + return { + ifcGuid: orUndefined(dto.ifc_guid), + originatingSystem: orUndefined(dto.originating_system), + authoringToolId: orUndefined(dto.authoring_tool_id), + }; +} + +/** + * Normalize a BCF API coloring color to the ARGB 8-hex form the BCF-XML + * model uses ('FFFF0000'). Servers send 6-hex RGB or 8-hex ARGB, with or + * without a leading '#'. Anything else is returned uppercased as-is rather + * than guessed at. + */ +export function normalizeArgbColor(color: string): string { + const hex = (color.startsWith('#') ? color.slice(1) : color).toUpperCase(); + if (/^[0-9A-F]{6}$/.test(hex)) return `FF${hex}`; + return hex; +} + +function coloringFromDto(dtos: BcfColoringDto[] | null | undefined): BCFColoring[] | undefined { + if (!dtos || dtos.length === 0) return undefined; + const result: BCFColoring[] = []; + for (const dto of dtos) { + if (typeof dto.color !== 'string' || dto.color.length === 0) continue; + result.push({ + color: normalizeArgbColor(dto.color), + components: (dto.components ?? []).map(componentFromDto), + }); + } + return result.length > 0 ? result : undefined; +} + +function visibilityFromDto(dto: BcfVisibilityDto | null | undefined): BCFVisibility | undefined { + if (!dto) return undefined; + const hints = dto.view_setup_hints; + return { + // BCF API 2.1 defines default_visibility as "optional, default false" on + // the wire (unlike BCF-XML, where a missing attribute reads as true), and + // servers encode isolation viewpoints by omitting it. `?? true` here would + // invert isolation into hiding the isolated components. + defaultVisibility: dto.default_visibility ?? false, + exceptions: + dto.exceptions && dto.exceptions.length > 0 + ? dto.exceptions.map(componentFromDto) + : undefined, + viewSetupHints: hints + ? { + spacesVisible: orUndefined(hints.spaces_visible), + spaceBoundariesVisible: orUndefined(hints.space_boundaries_visible), + openingsVisible: orUndefined(hints.openings_visible), + } + : undefined, + }; +} + +/** Map a components payload (inline or assembled from subresources). */ +export function componentsFromApi(dto: BcfComponentsDto): BCFComponents | undefined { + const selection = + dto.selection && dto.selection.length > 0 ? dto.selection.map(componentFromDto) : undefined; + const visibility = visibilityFromDto(dto.visibility); + const coloring = coloringFromDto(dto.coloring); + if (!selection && !visibility && !coloring) return undefined; + return { selection, visibility, coloring }; +} + +/** + * Map a viewpoint DTO. Malformed cameras (missing or non-finite fields) are + * dropped rather than fabricated, so a broken server viewpoint degrades to + * "no camera" instead of teleporting the user to the origin. The snapshot + * image is not part of the DTO; fetch it separately and set + * `viewpoint.snapshot` to a data URL. + */ +export function viewpointFromApi( + dto: BcfViewpointDto, + components?: BcfComponentsDto, +): BCFViewpoint { + const lines = (dto.lines ?? []) + .map((line) => { + const start = pointFromDto(line.start_point); + const end = pointFromDto(line.end_point); + return start && end ? { startPoint: start, endPoint: end } : undefined; + }) + .filter((line) => line !== undefined); + const clippingPlanes = (dto.clipping_planes ?? []) + .map((plane) => { + const location = pointFromDto(plane.location); + const direction = pointFromDto(plane.direction); + return location && direction ? { location, direction } : undefined; + }) + .filter((plane) => plane !== undefined); + + const componentsSource = components ?? dto.components; + return { + guid: dto.guid, + perspectiveCamera: perspectiveCameraFromDto(dto.perspective_camera), + orthogonalCamera: orthogonalCameraFromDto(dto.orthogonal_camera), + lines: lines.length > 0 ? lines : undefined, + clippingPlanes: clippingPlanes.length > 0 ? clippingPlanes : undefined, + components: componentsSource ? componentsFromApi(componentsSource) : undefined, + }; +} + +export function commentFromApi(dto: BcfCommentDto): BCFComment { + return { + guid: dto.guid, + date: dto.date ?? '', + author: dto.author ?? '', + comment: dto.comment ?? '', + viewpointGuid: orUndefined(dto.viewpoint_guid), + modifiedDate: orUndefined(dto.modified_date), + modifiedAuthor: orUndefined(dto.modified_author), + }; +} + +/** + * Map a topic DTO. Comments and viewpoints ship as empty arrays; the sync + * layer fills them from the per-topic collection endpoints. + */ +export function topicFromApi(dto: BcfTopicDto): BCFTopic { + return { + guid: dto.guid, + title: dto.title ?? 'Untitled topic', + description: orUndefined(dto.description), + topicType: orUndefined(dto.topic_type), + topicStatus: orUndefined(dto.topic_status), + priority: orUndefined(dto.priority), + index: isFiniteNumber(dto.index) ? dto.index : undefined, + creationDate: dto.creation_date ?? '', + creationAuthor: dto.creation_author ?? '', + modifiedDate: orUndefined(dto.modified_date), + modifiedAuthor: orUndefined(dto.modified_author), + dueDate: orUndefined(dto.due_date), + assignedTo: orUndefined(dto.assigned_to), + stage: orUndefined(dto.stage), + labels: dto.labels && dto.labels.length > 0 ? [...dto.labels] : undefined, + bimSnippet: dto.bim_snippet + ? { + snippetType: dto.bim_snippet.snippet_type, + isExternal: dto.bim_snippet.is_external ?? false, + reference: dto.bim_snippet.reference, + referenceSchema: orUndefined(dto.bim_snippet.reference_schema), + } + : undefined, + comments: [], + viewpoints: [], + }; +} + +export function extensionsFromApi(dto: BcfExtensionsDto): BCFExtensions | undefined { + const extensions: BCFExtensions = { + topicTypes: orUndefined(dto.topic_type), + topicStatuses: orUndefined(dto.topic_status), + priorities: orUndefined(dto.priority), + topicLabels: orUndefined(dto.topic_label), + users: orUndefined(dto.user_id_type), + stages: orUndefined(dto.stage), + }; + const hasAny = Object.values(extensions).some((list) => list && list.length > 0); + return hasAny ? extensions : undefined; +} diff --git a/packages/bcf-api/src/sync.test.ts b/packages/bcf-api/src/sync.test.ts new file mode 100644 index 0000000000..21ea5f9c7c --- /dev/null +++ b/packages/bcf-api/src/sync.test.ts @@ -0,0 +1,245 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'vitest'; +import { BcfApiClient } from './client.js'; +import { fetchProjectAsBCF } from './sync.js'; +import type { BcfTopicDto, FetchLike } from './types.js'; + +interface FakeServerOptions { + topics: BcfTopicDto[]; + /** When true the server returns the full topic list regardless of paging. */ + ignorePaging?: boolean; + /** Viewpoint guids whose snapshot endpoint should fail. */ + brokenSnapshots?: string[]; + /** Topic guids whose /comments endpoint fails with this HTTP status. */ + brokenComments?: { guids: string[]; status: number }; +} + +/** Minimal in-memory BCF 2.1 server: one project, one viewpoint per topic. */ +function fakeBcfServer(options: FakeServerOptions): FetchLike { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + return async (rawUrl) => { + const url = new URL(rawUrl); + const path = url.pathname; + if (path.endsWith('/projects/p1')) return json({ project_id: 'p1', name: 'Fake project' }); + if (path.endsWith('/extensions')) return json({ topic_status: ['Open', 'Closed'] }); + if (path.endsWith('/topics')) { + if (options.ignorePaging) return json(options.topics); + const top = Number(url.searchParams.get('$top') ?? options.topics.length); + const skip = Number(url.searchParams.get('$skip') ?? 0); + return json(options.topics.slice(skip, skip + top)); + } + if (path.endsWith('/comments')) { + const topicGuid = /topics\/([^/]+)\/comments$/.exec(path)?.[1]; + if (topicGuid && options.brokenComments?.guids.includes(topicGuid)) { + return json({ message: 'comments broken' }, options.brokenComments.status); + } + return json([ + { guid: `comment-${topicGuid}`, author: 'alice@example.com', comment: `on ${topicGuid}` }, + ]); + } + if (path.endsWith('/viewpoints')) { + const topicGuid = /topics\/([^/]+)\/viewpoints$/.exec(path)?.[1]; + return json([ + { + guid: `vp-${topicGuid}`, + snapshot: { snapshot_type: 'png' }, + perspective_camera: { + camera_view_point: { x: 1, y: 2, z: 3 }, + camera_direction: { x: 0, y: 1, z: 0 }, + camera_up_vector: { x: 0, y: 0, z: 1 }, + field_of_view: 60, + }, + }, + ]); + } + if (path.endsWith('/selection')) return json({ selection: [{ ifc_guid: 'guid_selection_000000A' }] }); + if (path.endsWith('/coloring')) return json({ coloring: [] }); + if (path.endsWith('/visibility')) return json({ visibility: { default_visibility: true } }); + if (path.endsWith('/snapshot')) { + const viewpointGuid = /viewpoints\/([^/]+)\/snapshot$/.exec(path)?.[1]; + if (viewpointGuid && options.brokenSnapshots?.includes(viewpointGuid)) { + return json({ message: 'snapshot missing' }, 404); + } + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { 'Content-Type': 'image/png' }, + }); + } + return json({ message: `unhandled path ${path}` }, 500); + }; +} + +function makeTopics(count: number): BcfTopicDto[] { + return Array.from({ length: count }, (_, i) => ({ + guid: `topic-${i}`, + title: `Topic ${i}`, + topic_status: 'Open', + creation_date: '2026-08-01T10:00:00Z', + creation_author: 'alice@example.com', + })); +} + +function makeClient(fetchFn: FetchLike): BcfApiClient { + return new BcfApiClient({ baseUrl: 'https://host/bcf', fetchFn }); +} + +describe('fetchProjectAsBCF', () => { + it('assembles a complete BCFProject: metadata, extensions, topics, comments, viewpoints, snapshots', async () => { + const client = makeClient(fakeBcfServer({ topics: makeTopics(3) })); + const { project, warnings } = await fetchProjectAsBCF(client, 'p1'); + expect(warnings).toEqual([]); + expect(project.version).toBe('2.1'); + expect(project.projectId).toBe('p1'); + expect(project.name).toBe('Fake project'); + expect(project.extensions?.topicStatuses).toEqual(['Open', 'Closed']); + expect(project.topics.size).toBe(3); + const topic = project.topics.get('topic-1'); + expect(topic?.title).toBe('Topic 1'); + expect(topic?.comments).toHaveLength(1); + expect(topic?.comments[0].comment).toBe('on topic-1'); + expect(topic?.viewpoints).toHaveLength(1); + const viewpoint = topic?.viewpoints[0]; + expect(viewpoint?.perspectiveCamera?.fieldOfView).toBe(60); + expect(viewpoint?.components?.selection?.[0].ifcGuid).toBe('guid_selection_000000A'); + expect(viewpoint?.snapshot).toMatch(/^data:image\/png;base64,/); + }); + + it('pages through the topics collection with $top/$skip', async () => { + const requests: string[] = []; + const server = fakeBcfServer({ topics: makeTopics(5) }); + const client = makeClient(async (url, init) => { + if (url.includes('/topics?')) requests.push(url); + return server(url, init); + }); + const { project } = await fetchProjectAsBCF(client, 'p1', { + pageSize: 2, + includeSnapshots: false, + }); + expect(project.topics.size).toBe(5); + expect(requests).toHaveLength(3); + expect(requests[1]).toContain('%24skip=2'); + expect(requests[2]).toContain('%24skip=4'); + }); + + it('terminates against a server that ignores paging parameters', async () => { + // 2 topics with pageSize 2 look like a full page; without the fresh-guid + // guard the second identical page would loop forever. + const client = makeClient(fakeBcfServer({ topics: makeTopics(2), ignorePaging: true })); + const { project } = await fetchProjectAsBCF(client, 'p1', { + pageSize: 2, + includeSnapshots: false, + }); + expect(project.topics.size).toBe(2); + }); + + it('truncates at maxTopics with a warning instead of pulling unbounded', async () => { + const client = makeClient(fakeBcfServer({ topics: makeTopics(10) })); + const { project, warnings } = await fetchProjectAsBCF(client, 'p1', { + pageSize: 2, + maxTopics: 4, + includeSnapshots: false, + }); + expect(project.topics.size).toBe(4); + expect(warnings.some((w) => w.includes('truncated at 4'))).toBe(true); + }); + + it('enforces maxTopics even when a single page exceeds it', async () => { + const client = makeClient(fakeBcfServer({ topics: makeTopics(10) })); + const { project, warnings } = await fetchProjectAsBCF(client, 'p1', { + pageSize: 10, + maxTopics: 3, + includeSnapshots: false, + }); + expect(project.topics.size).toBe(3); + expect(warnings.some((w) => w.includes('truncated at 3'))).toBe(true); + }); + + it('does not warn about truncation when the server has exactly maxTopics topics', async () => { + const client = makeClient(fakeBcfServer({ topics: makeTopics(4) })); + const { project, warnings } = await fetchProjectAsBCF(client, 'p1', { + pageSize: 2, + maxTopics: 4, + includeSnapshots: false, + }); + expect(project.topics.size).toBe(4); + expect(warnings).toEqual([]); + }); + + it('skips guid-less topics with a warning instead of collapsing them', async () => { + const topics = makeTopics(2); + const noGuid: BcfTopicDto = { guid: '', title: 'broken A' }; + const noGuid2: BcfTopicDto = { guid: '', title: 'broken B' }; + const client = makeClient(fakeBcfServer({ topics: [topics[0], noGuid, noGuid2, topics[1]] })); + const { project, warnings } = await fetchProjectAsBCF(client, 'p1', { + includeSnapshots: false, + }); + expect(project.topics.size).toBe(2); + expect([...project.topics.keys()]).toEqual(['topic-0', 'topic-1']); + expect(warnings.some((w) => w.includes('Skipped 2 topic(s)'))).toBe(true); + }); + + it('degrades one topic\'s failed details to a warning and keeps the rest of the pull', async () => { + const client = makeClient( + fakeBcfServer({ topics: makeTopics(3), brokenComments: { guids: ['topic-1'], status: 500 } }), + ); + const { project, warnings } = await fetchProjectAsBCF(client, 'p1', { + includeSnapshots: false, + }); + expect(project.topics.size).toBe(3); + // The broken topic keeps its listed metadata, just without details. + expect(project.topics.get('topic-1')?.comments).toEqual([]); + expect(project.topics.get('topic-0')?.comments).toHaveLength(1); + expect(warnings.some((w) => w.includes('Details unavailable for topic topic-1'))).toBe(true); + }); + + it('still fails the whole pull when topic details fail with 401 (expired session)', async () => { + const client = makeClient( + fakeBcfServer({ topics: makeTopics(3), brokenComments: { guids: ['topic-1'], status: 401 } }), + ); + await expect( + fetchProjectAsBCF(client, 'p1', { includeSnapshots: false }), + ).rejects.toMatchObject({ status: 401 }); + }); + + it('degrades a failed snapshot to a warning and keeps the viewpoint', async () => { + const client = makeClient( + fakeBcfServer({ topics: makeTopics(1), brokenSnapshots: ['vp-topic-0'] }), + ); + const { project, warnings } = await fetchProjectAsBCF(client, 'p1'); + const viewpoint = project.topics.get('topic-0')?.viewpoints[0]; + expect(viewpoint).toBeDefined(); + expect(viewpoint?.snapshot).toBeUndefined(); + expect(viewpoint?.perspectiveCamera).toBeDefined(); + expect(warnings.some((w) => w.includes('Snapshot unavailable'))).toBe(true); + }); + + it('fails fast when the topics collection itself is unreachable', async () => { + const client = makeClient(async () => + new Response(JSON.stringify({ message: 'Not authenticated' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await expect(fetchProjectAsBCF(client, 'p1')).rejects.toMatchObject({ + status: 401, + }); + }); + + it('reports progress for topic pages and per-topic details', async () => { + const phases: string[] = []; + const client = makeClient(fakeBcfServer({ topics: makeTopics(3) })); + await fetchProjectAsBCF(client, 'p1', { + includeSnapshots: false, + onProgress: (p) => phases.push(`${p.phase}:${p.loaded}${p.total ? `/${p.total}` : ''}`), + }); + expect(phases).toContain('topics:3'); + expect(phases).toContain('details:3/3'); + }); +}); diff --git a/packages/bcf-api/src/sync.ts b/packages/bcf-api/src/sync.ts new file mode 100644 index 0000000000..5c89bdb008 --- /dev/null +++ b/packages/bcf-api/src/sync.ts @@ -0,0 +1,276 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * High-level pull: fetch a whole BCF project (topics, comments, viewpoints, + * components, snapshots) from a BCF server into an `@ifc-lite/bcf` + * `BCFProject`, ready for `setBcfProject` in the viewer or `writeBCF` on any + * host. + */ + +import type { BCFExtensions, BCFProject, BCFTopic } from '@ifc-lite/bcf'; +import type { BcfApiClient } from './client.js'; +import { BcfApiError } from './errors.js'; +import { + componentsFromApi, + commentFromApi, + extensionsFromApi, + topicFromApi, + viewpointFromApi, +} from './mapping.js'; +import type { BcfComponentsDto, BcfTopicDto, BcfViewpointDto } from './types.js'; + +export interface BcfSyncProgress { + phase: 'topics' | 'details' | 'snapshots'; + /** Completed units in this phase (topic pages, topics, or snapshots). */ + loaded: number; + /** Total units when known; topic paging cannot know it up front. */ + total?: number; +} + +export interface FetchProjectOptions { + /** Page size for the topics collection; defaults to 100. */ + pageSize?: number; + /** Hard cap on fetched topics; defaults to 1000. */ + maxTopics?: number; + /** Fetch viewpoint snapshot images as data URLs; defaults to true. */ + includeSnapshots?: boolean; + /** OData `$filter` passed through to the topics collection. */ + filter?: string; + /** OData `$orderby` passed through to the topics collection. */ + orderby?: string; + /** Concurrent per-topic detail requests; defaults to 4. */ + concurrency?: number; + onProgress?: (progress: BcfSyncProgress) => void; +} + +export interface BcfProjectFetchResult { + project: BCFProject; + /** + * Non-fatal failures (a viewpoint whose components endpoint errored, a + * snapshot that would not load). The project is complete except for the + * listed items; callers surface these instead of failing the whole pull. + */ + warnings: string[]; +} + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + task: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await task(items[index], index); + } + }); + await Promise.all(workers); + return results; +} + +async function blobToDataUrl(blob: Blob): Promise { + const bytes = new Uint8Array(await blob.arrayBuffer()); + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return `data:${blob.type || 'image/png'};base64,${btoa(binary)}`; +} + +async function fetchAllTopics( + client: BcfApiClient, + projectId: string, + options: FetchProjectOptions, + warnings: string[], +): Promise { + const pageSize = options.pageSize ?? 100; + const maxTopics = options.maxTopics ?? 1000; + const topics: BcfTopicDto[] = []; + const seen = new Set(); + let skippedNoGuid = 0; + let truncated = false; + let skip = 0; + let page = 0; + for (;;) { + const batch = await client.getTopics(projectId, { + filter: options.filter, + orderby: options.orderby, + top: pageSize, + skip, + }); + page += 1; + let fresh = 0; + for (const topic of batch) { + // A guid-less topic can't be keyed, deduped, or addressed by any later + // request; deduping on `undefined` would silently collapse them all. + if (typeof topic.guid !== 'string' || topic.guid.length === 0) { + skippedNoGuid += 1; + continue; + } + if (seen.has(topic.guid)) continue; + seen.add(topic.guid); + fresh += 1; + if (topics.length < maxTopics) { + topics.push(topic); + } else { + truncated = true; + } + } + options.onProgress?.({ phase: 'topics', loaded: topics.length }); + if (truncated) { + warnings.push(`Topic list truncated at ${maxTopics} topics (server has more)`); + break; + } + // A server that ignores $top/$skip returns the same page forever; the + // fresh-count guard turns that into a single-page fetch instead of a loop. + if (batch.length < pageSize || fresh === 0) break; + skip = page * pageSize; + } + if (skippedNoGuid > 0) { + warnings.push(`Skipped ${skippedNoGuid} topic(s) the server sent without a guid`); + } + return topics; +} + +async function fetchViewpointComponents( + client: BcfApiClient, + projectId: string, + topicGuid: string, + viewpoint: BcfViewpointDto, + warnings: string[], +): Promise { + // The list form usually inlines nothing; 2.1 serves components through the + // three subresources. Skip the extra round-trips when the DTO carries them. + if (viewpoint.components && componentsFromApi(viewpoint.components)) { + return viewpoint.components; + } + try { + const [selection, coloring, visibility] = await Promise.all([ + client.getViewpointSelection(projectId, topicGuid, viewpoint.guid), + client.getViewpointColoring(projectId, topicGuid, viewpoint.guid), + client.getViewpointVisibility(projectId, topicGuid, viewpoint.guid), + ]); + return { + selection: selection.selection, + coloring: coloring.coloring, + visibility: visibility.visibility, + }; + } catch (error) { + warnings.push( + `Components unavailable for viewpoint ${viewpoint.guid}: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } +} + +async function fetchTopicDetails( + client: BcfApiClient, + projectId: string, + dto: BcfTopicDto, + includeSnapshots: boolean, + warnings: string[], +): Promise { + const topic = topicFromApi(dto); + let comments; + let viewpoints; + try { + [comments, viewpoints] = await Promise.all([ + client.getComments(projectId, dto.guid), + client.getViewpoints(projectId, dto.guid), + ]); + } catch (error) { + // An expired session fails every topic the same way — surface that once, + // loudly. Anything else (topic deleted mid-pull, one flaky 500) keeps the + // listed metadata and degrades to a warning. + if (error instanceof BcfApiError && error.isAuthError) throw error; + warnings.push( + `Details unavailable for topic ${dto.guid}: ${error instanceof Error ? error.message : String(error)}`, + ); + return topic; + } + topic.comments = comments.map(commentFromApi); + for (const viewpointDto of viewpoints) { + const components = await fetchViewpointComponents( + client, + projectId, + dto.guid, + viewpointDto, + warnings, + ); + const viewpoint = viewpointFromApi(viewpointDto, components); + if (includeSnapshots && viewpointDto.snapshot) { + try { + const blob = await client.getViewpointSnapshot(projectId, dto.guid, viewpointDto.guid); + viewpoint.snapshot = await blobToDataUrl(blob); + } catch (error) { + warnings.push( + `Snapshot unavailable for viewpoint ${viewpointDto.guid}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + topic.viewpoints.push(viewpoint); + } + return topic; +} + +/** + * Pull one project from a BCF server into a `BCFProject`. Fails fast when + * the topics collection is unreachable or the session expires (401); + * degrades per-item — with a warning — when an individual topic's details, + * a viewpoint's components, or a snapshot cannot be fetched. + */ +export async function fetchProjectAsBCF( + client: BcfApiClient, + projectId: string, + options: FetchProjectOptions = {}, +): Promise { + const warnings: string[] = []; + const includeSnapshots = options.includeSnapshots ?? true; + + let projectName: string | undefined; + try { + projectName = (await client.getProject(projectId)).name ?? undefined; + } catch (error) { + warnings.push( + `Project metadata unavailable: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let extensions: BCFExtensions | undefined; + try { + extensions = extensionsFromApi(await client.getExtensions(projectId)); + } catch (error) { + warnings.push( + `Extensions unavailable: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const topicDtos = await fetchAllTopics(client, projectId, options, warnings); + + let detailed = 0; + const topics = await mapWithConcurrency( + topicDtos, + options.concurrency ?? 4, + async (dto) => { + const topic = await fetchTopicDetails(client, projectId, dto, includeSnapshots, warnings); + detailed += 1; + options.onProgress?.({ phase: 'details', loaded: detailed, total: topicDtos.length }); + return topic; + }, + ); + + const project: BCFProject = { + version: '2.1', + projectId, + name: projectName, + topics: new Map(topics.map((topic) => [topic.guid, topic])), + extensions, + }; + return { project, warnings }; +} diff --git a/packages/bcf-api/src/types.ts b/packages/bcf-api/src/types.ts new file mode 100644 index 0000000000..84c9de86f5 --- /dev/null +++ b/packages/bcf-api/src/types.ts @@ -0,0 +1,233 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * Wire types for the buildingSMART BCF API (OpenCDE) REST services. + * Field names are snake_case exactly as they appear in the JSON payloads of + * BCF API 2.1 (https://github.com/buildingSMART/BCF-API); BCF API 3.0 uses + * the same shapes for everything this client touches. + */ + +/** Entry of `GET {base}/versions`. */ +export interface BcfApiVersion { + version_id: string; + detailed_version?: string | null; +} + +/** Response of `GET {base}/{version}/auth`. */ +export interface BcfAuthInfo { + oauth2_auth_url?: string; + oauth2_token_url?: string; + oauth2_dynamic_client_reg_url?: string; + http_basic_supported?: boolean | null; + supported_oauth2_flows?: string[]; +} + +/** Response of `GET {base}/{version}/current-user`. */ +export interface BcfCurrentUser { + id: string; + name?: string | null; +} + +/** Entry of `GET {base}/{version}/projects`. */ +export interface BcfProjectDto { + project_id: string; + name?: string | null; + authorization?: { + project_actions?: string[] | null; + } | null; +} + +/** Response of `GET .../projects/{id}/extensions`. */ +export interface BcfExtensionsDto { + topic_type?: string[] | null; + topic_status?: string[] | null; + topic_label?: string[] | null; + snippet_type?: string[] | null; + priority?: string[] | null; + user_id_type?: string[] | null; + stage?: string[] | null; + project_actions?: string[] | null; + topic_actions?: string[] | null; + comment_actions?: string[] | null; +} + +export interface BcfBimSnippetDto { + snippet_type: string; + is_external?: boolean | null; + reference: string; + reference_schema?: string | null; +} + +/** Entry of `GET .../projects/{id}/topics`. */ +export interface BcfTopicDto { + guid: string; + topic_type?: string | null; + topic_status?: string | null; + reference_links?: string[] | null; + title?: string | null; + priority?: string | null; + index?: number | null; + labels?: string[] | null; + creation_date?: string | null; + creation_author?: string | null; + modified_date?: string | null; + modified_author?: string | null; + assigned_to?: string | null; + stage?: string | null; + description?: string | null; + due_date?: string | null; + bim_snippet?: BcfBimSnippetDto | null; + authorization?: { + topic_actions?: string[] | null; + } | null; +} + +/** Body of `POST .../topics` / `PUT .../topics/{guid}`. */ +export interface BcfTopicWriteDto { + topic_type?: string | null; + topic_status?: string | null; + title: string; + priority?: string | null; + labels?: string[] | null; + assigned_to?: string | null; + stage?: string | null; + description?: string | null; + due_date?: string | null; +} + +/** Entry of `GET .../topics/{guid}/comments`. */ +export interface BcfCommentDto { + guid: string; + date?: string | null; + author?: string | null; + comment?: string | null; + topic_guid?: string | null; + viewpoint_guid?: string | null; + reply_to_comment_guid?: string | null; + modified_date?: string | null; + modified_author?: string | null; +} + +/** Body of `POST .../topics/{guid}/comments`. */ +export interface BcfCommentWriteDto { + comment: string; + viewpoint_guid?: string | null; + reply_to_comment_guid?: string | null; +} + +export interface BcfPointDto { + x: number; + y: number; + z: number; +} + +export interface BcfPerspectiveCameraDto { + camera_view_point: BcfPointDto; + camera_direction: BcfPointDto; + camera_up_vector: BcfPointDto; + field_of_view: number; + aspect_ratio?: number | null; +} + +export interface BcfOrthogonalCameraDto { + camera_view_point: BcfPointDto; + camera_direction: BcfPointDto; + camera_up_vector: BcfPointDto; + view_to_world_scale: number; + aspect_ratio?: number | null; +} + +export interface BcfLineDto { + start_point: BcfPointDto; + end_point: BcfPointDto; +} + +export interface BcfClippingPlaneDto { + location: BcfPointDto; + direction: BcfPointDto; +} + +export interface BcfComponentDto { + ifc_guid?: string | null; + originating_system?: string | null; + authoring_tool_id?: string | null; +} + +export interface BcfViewSetupHintsDto { + spaces_visible?: boolean | null; + space_boundaries_visible?: boolean | null; + openings_visible?: boolean | null; +} + +export interface BcfVisibilityDto { + default_visibility?: boolean | null; + exceptions?: BcfComponentDto[] | null; + view_setup_hints?: BcfViewSetupHintsDto | null; +} + +export interface BcfColoringDto { + color: string; + components?: BcfComponentDto[] | null; +} + +export interface BcfComponentsDto { + selection?: BcfComponentDto[] | null; + coloring?: BcfColoringDto[] | null; + visibility?: BcfVisibilityDto | null; +} + +/** + * Entry of `GET .../topics/{guid}/viewpoints`. The list form usually omits + * `components`; BCF API 2.1 serves those via the per-viewpoint + * `/selection`, `/coloring` and `/visibility` subresources instead. + */ +export interface BcfViewpointDto { + guid: string; + index?: number | null; + perspective_camera?: BcfPerspectiveCameraDto | null; + orthogonal_camera?: BcfOrthogonalCameraDto | null; + lines?: BcfLineDto[] | null; + clipping_planes?: BcfClippingPlaneDto[] | null; + snapshot?: null | { + snapshot_type?: string | null; + /** Base64 payload; only present in POST bodies, never in GET responses. */ + snapshot_data?: string | null; + }; + components?: BcfComponentsDto | null; +} + +/** Response of `GET .../viewpoints/{guid}/selection`. */ +export interface BcfSelectionResponse { + selection?: BcfComponentDto[] | null; +} + +/** Response of `GET .../viewpoints/{guid}/coloring`. */ +export interface BcfColoringResponse { + coloring?: BcfColoringDto[] | null; +} + +/** Response of `GET .../viewpoints/{guid}/visibility`. */ +export interface BcfVisibilityResponse { + visibility?: BcfVisibilityDto | null; +} + +/** + * OAuth2 token response of `POST {oauth2_token_url}`. Unlike the wire DTOs + * above, this is what the auth helpers RETURN after field-by-field + * validation (`postTokenRequest`), so absent fields are always `undefined`, + * never `null`. + */ +export interface BcfTokenResponse { + access_token: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; +} + +/** Minimal fetch signature the client depends on (injectable in tests). */ +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +/** Supplies the current access token; return undefined for anonymous calls. */ +export type BcfTokenProvider = () => string | undefined | Promise; diff --git a/packages/bcf-api/tsconfig.json b/packages/bcf-api/tsconfig.json new file mode 100644 index 0000000000..f7f3a6ddea --- /dev/null +++ b/packages/bcf-api/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/bcf-api/vitest.config.ts b/packages/bcf-api/vitest.config.ts new file mode 100644 index 0000000000..963551487c --- /dev/null +++ b/packages/bcf-api/vitest.config.ts @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@ifc-lite/bcf': path.resolve(__dirname, '../bcf/src/index.ts'), + '@ifc-lite/encoding': path.resolve(__dirname, '../encoding/src/index.ts'), + }, + }, + test: { + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc5a373b1f..2f4f9580e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@ifc-lite/bcf': specifier: workspace:^ version: link:../../packages/bcf + '@ifc-lite/bcf-api': + specifier: workspace:^ + version: link:../../packages/bcf-api '@ifc-lite/cache': specifier: workspace:^ version: link:../../packages/cache @@ -171,6 +174,9 @@ importers: '@ifc-lite/mutations': specifier: workspace:^ version: link:../../packages/mutations + '@ifc-lite/oauth-pkce': + specifier: workspace:^ + version: link:../../packages/oauth-pkce '@ifc-lite/parser': specifier: workspace:^ version: link:../../packages/parser @@ -638,6 +644,19 @@ importers: specifier: 5.3.0 version: 5.3.0(@types/node@26.2.0) + packages/bcf-api: + dependencies: + '@ifc-lite/bcf': + specifier: workspace:^ + version: link:../bcf + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.0 + version: 4.1.10(@edge-runtime/vm@3.2.0)(@types/node@26.2.0)(happy-dom@20.11.2)(vite@8.2.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + packages/cache: dependencies: '@ifc-lite/data': diff --git a/scripts/api-surface.json b/scripts/api-surface.json index a3b71e6333..5abb2deaf1 100644 --- a/scripts/api-surface.json +++ b/scripts/api-surface.json @@ -66,6 +66,55 @@ "uuidToIfcGuid: function", "writeBCF: function" ], + "@ifc-lite/bcf-api": [ + "AuthorizationCodeGrantOptions: interface", + "BcfApiClient: class", + "BcfApiClientOptions: interface", + "BcfApiError: class", + "BcfApiVersion: interface", + "BcfAuthInfo: interface", + "BcfAuthenticationError: class", + "BcfClippingPlaneDto: interface", + "BcfColoringDto: interface", + "BcfColoringResponse: interface", + "BcfCommentDto: interface", + "BcfCommentWriteDto: interface", + "BcfComponentDto: interface", + "BcfComponentsDto: interface", + "BcfCurrentUser: interface", + "BcfExtensionsDto: interface", + "BcfLineDto: interface", + "BcfOrthogonalCameraDto: interface", + "BcfPerspectiveCameraDto: interface", + "BcfPointDto: interface", + "BcfProjectDto: interface", + "BcfProjectFetchResult: interface", + "BcfSelectionResponse: interface", + "BcfSyncProgress: interface", + "BcfTokenProvider: type", + "BcfTokenResponse: interface", + "BcfTopicDto: interface", + "BcfTopicWriteDto: interface", + "BcfViewSetupHintsDto: interface", + "BcfViewpointDto: interface", + "BcfVisibilityDto: interface", + "BcfVisibilityResponse: interface", + "ClientCredentialsGrantOptions: interface", + "FetchLike: type", + "FetchProjectOptions: interface", + "PasswordGrantOptions: interface", + "RefreshGrantOptions: interface", + "RegisterClientOptions: interface", + "RegisteredClient: interface", + "TopicQueryOptions: interface", + "exchangeAuthorizationCode: function", + "fetchProjectAsBCF: function", + "normalizeBcfBaseUrl: function", + "refreshAccessToken: function", + "registerBcfClient: function", + "requestClientCredentialsToken: function", + "requestPasswordToken: function" + ], "@ifc-lite/cache": [ "BinaryCacheReader: class", "BinaryCacheWriter: class", diff --git a/vercel.json b/vercel.json index 5483ca8d36..33378c2bde 100644 --- a/vercel.json +++ b/vercel.json @@ -25,6 +25,10 @@ "source": "/oauth/msgraph/callback", "destination": "/oauth/msgraph/callback.html" }, + { + "source": "/oauth/bcf/callback", + "destination": "/oauth/bcf/callback.html" + }, { "source": "/mcp", "destination": "/index.html" From 9f1a88caf3a546d673b1be38a42383881e14595c Mon Sep 17 00:00:00 2001 From: Jonatan Jacobsson Date: Wed, 26 Aug 2026 06:21:45 +0200 Subject: [PATCH 2/3] fix(bcf-api,viewer): address review findings on the BCF server connector Security (per-session token isolation): the single-flight refresh is now keyed by serverUrl, so a client bound to one server can never join another server's in-flight refresh and send it that server's bearer token; and a completed refresh persists only when the stored connection is still the same SESSION (server + user + unchanged refresh material), so signing in as a different account on the same server is not clobbered by the old account's stale refresh. Correctness: 401s from viewpoint components/snapshot requests now fail the pull like every other auth failure instead of degrading to warnings; maxTopics/pageSize are floored so a fractional cap cannot admit an extra topic; switching to a tenant-hosted preset (BIMcollab, OpenProject) clears the previous preset's URL; a blocked OAuth popup fails before discovery/dynamic registration can mint a throwaway client; stale project-list responses from a disconnected or replaced connection are dropped via a request generation; resolveFetch returns a wrapped fetch (browser receiver brand-check); docs state the warning-vs-fatal contract and list the browser OAuth method. Co-Authored-By: Claude Fable 5 --- .../viewer/bcf/BCFServerConnectForm.tsx | 20 ++- .../viewer/bcf/BCFServerDialog.test.tsx | 23 +++- .../components/viewer/bcf/BCFServerDialog.tsx | 9 ++ apps/viewer/src/services/bcf-server.test.ts | 40 ++++++ apps/viewer/src/services/bcf-server.ts | 120 +++++++++++------- docs/guide/bcf.md | 4 +- packages/bcf-api/README.md | 4 +- packages/bcf-api/src/auth.ts | 5 +- packages/bcf-api/src/sync.test.ts | 54 +++++++- packages/bcf-api/src/sync.ts | 10 +- 10 files changed, 227 insertions(+), 62 deletions(-) diff --git a/apps/viewer/src/components/viewer/bcf/BCFServerConnectForm.tsx b/apps/viewer/src/components/viewer/bcf/BCFServerConnectForm.tsx index 3d5b57804d..a977e5a118 100644 --- a/apps/viewer/src/components/viewer/bcf/BCFServerConnectForm.tsx +++ b/apps/viewer/src/components/viewer/bcf/BCFServerConnectForm.tsx @@ -33,6 +33,7 @@ import { import { AUTH_METHOD_LABELS, BCF_SERVER_PRESETS, + CUSTOM_PRESET_ID, findBcfServerPreset, presetForServerUrl, type BcfAuthMethod, @@ -67,7 +68,11 @@ export function BCFServerConnectForm({ const handlePresetChange = useCallback((id: string) => { const next = findBcfServerPreset(id); setPresetId(next.id); - if (next.baseUrl) setServerUrl(next.baseUrl); + // Every named preset owns the URL field: fixed servers fill it, and + // tenant-hosted ones (BIMcollab, OpenProject) CLEAR it — carrying the + // previous preset's URL over would label one server while connecting + // to another. Only Custom keeps whatever the user typed. + if (next.id !== CUSTOM_PRESET_ID) setServerUrl(next.baseUrl); setAuthMethod((current) => next.authMethods.includes(current) ? current : next.authMethods[0], ); @@ -86,6 +91,13 @@ export function BCFServerConnectForm({ authMethod === 'oauth' ? window.open('about:blank', 'ifc-lite-bcf-oauth', 'width=500,height=700') : null; + // A blocked popup fails the attempt before any network work — running + // discovery and dynamic client registration for a sign-in that cannot + // complete would mint throwaway clients on the server. + if (authMethod === 'oauth' && (!popup || popup.closed)) { + setError('Sign-in popup was blocked — allow popups for this site and try again.'); + return; + } setBusy(true); setError(null); try { @@ -95,14 +107,14 @@ export function BCFServerConnectForm({ } else if (authMethod === 'token') { config = await signInWithToken(serverUrl, accessToken); } else if (authMethod === 'oauth') { + if (!popup) { + throw new Error('Sign-in popup was blocked — allow popups for this site and try again.'); + } const preparation = await prepareBcfOAuth(serverUrl, { clientId, clientSecret, scope: preset.oauthScope, }); - if (!popup || popup.closed) { - throw new Error('Sign-in popup was blocked — allow popups for this site and try again.'); - } // Subscribe before navigating: BroadcastChannel does not buffer. const { waitForOAuthCallback } = await import('@ifc-lite/oauth-pkce'); const callback = waitForOAuthCallback({ diff --git a/apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx index 9aabba3381..ab6321e1dd 100644 --- a/apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx +++ b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.test.tsx @@ -193,6 +193,17 @@ describe('BCFServerDialog', () => { assert.equal(document.body.querySelector('#bcf-server-oauth-client-id'), null); }); + it('clears the URL when switching from a fixed preset to a tenant-hosted one', () => { + installFakeServer(); + render( {}} />); + chooseOption('bcf-server-preset', 'BIMData.io'); + assert.equal(input('bcf-server-url').value, 'https://api.bimdata.io/bcf'); + // BIMcollab is per-space: carrying BIMData's URL over would label one + // server while connecting to another. + chooseOption('bcf-server-preset', 'BIMcollab'); + assert.equal(input('bcf-server-url').value, ''); + }); + it('connects with a pasted access token', async () => { installFakeServer(); render( {}} />); @@ -269,14 +280,22 @@ describe('BCFServerDialog', () => { it('rejects a plain-http server URL before contacting it', async () => { installFakeServer(); + const requested: string[] = []; + const fakeServer = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requested.push(String(input)); + return fakeServer(input, init); + }) as typeof fetch; render( {}} />); type(input('bcf-server-url'), 'http://insecure.example/bcf'); type(input('bcf-server-user'), 'tester@example.com'); type(input('bcf-server-password'), 'right'); click(button('Connect')); await waitFor( - () => document.body.textContent?.includes('https://') ?? false, - 'https requirement message', + () => + document.body.textContent?.includes('BCF server URLs must use https://') ?? false, + 'the validateBcfServerUrl message', ); + assert.deepEqual(requested, [], 'no request may reach an http server'); }); }); diff --git a/apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx index 242788f154..d7eaa1b80f 100644 --- a/apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx +++ b/apps/viewer/src/components/viewer/bcf/BCFServerDialog.tsx @@ -64,15 +64,23 @@ export function BCFServerDialog({ open, onOpenChange }: BCFServerDialogProps) { // user must confirm before we overwrite them (they may be unexported). const [replaceCount, setReplaceCount] = useState(null); + // A project-list request can outlive the connection it was issued for + // (disconnect, or sign-in to another server, mid-flight). Results carrying + // a stale generation are dropped so they can never pair the new + // connection with the old server's project list. + const projectsGenerationRef = useRef(0); const loadProjects = useCallback(async () => { + const generation = ++projectsGenerationRef.current; try { const list = await listBcfServerProjects(); + if (generation !== projectsGenerationRef.current) return; setProjects(list); setSelectedProjectId((current) => { if (current && list.some((p) => p.project_id === current)) return current; return list[0]?.project_id ?? ''; }); } catch (err) { + if (generation !== projectsGenerationRef.current) return; setProjects([]); setError(err instanceof Error ? err.message : String(err)); } @@ -111,6 +119,7 @@ export function BCFServerDialog({ open, onOpenChange }: BCFServerDialogProps) { ); const handleDisconnect = useCallback(() => { + projectsGenerationRef.current += 1; clearBcfServerConfig(); setConfig(null); setProjects(null); diff --git a/apps/viewer/src/services/bcf-server.test.ts b/apps/viewer/src/services/bcf-server.test.ts index 360ccee7e8..32235e7009 100644 --- a/apps/viewer/src/services/bcf-server.test.ts +++ b/apps/viewer/src/services/bcf-server.test.ts @@ -307,6 +307,46 @@ describe('createConnectedClient token refresh', () => { assert.deepEqual(server.grants, ['refresh_token']); }); + it('does not write a stale refresh over a different account on the same server', async () => { + const server = installFakeServer(); + let openGate = () => {}; + server.tokenGate = new Promise((resolve) => { + openGate = resolve; + }); + saveBcfServerConfig({ + serverUrl: 'https://fake.example/bcf', + userId: 'old-account@example.com', + accessToken: 'expired-token', + refreshToken: 'refresh-1', + tokenExpiresAt: Date.now() - 1000, + clientId: '', + clientSecret: '', + projectId: '', + projectName: '', + }); + const client = await createConnectedClient(); + const pending = client.getProjects(); + // A different account signs in on the SAME server while the old + // account's refresh is still in flight. + saveBcfServerConfig({ + serverUrl: 'https://fake.example/bcf', + userId: 'new-account@example.com', + accessToken: 'token-1', + refreshToken: 'refresh-other', + tokenExpiresAt: Date.now() + 3_600_000, + clientId: '', + clientSecret: '', + projectId: '', + projectName: '', + }); + openGate(); + await pending; + const stored = loadBcfServerConfig(); + assert.equal(stored?.userId, 'new-account@example.com'); + assert.equal(stored?.accessToken, 'token-1', 'replacement session must keep its own tokens'); + assert.equal(stored?.refreshToken, 'refresh-other'); + }); + it('does not resurrect the session when the user disconnects during a refresh', async () => { const server = installFakeServer(); let openGate = () => {}; diff --git a/apps/viewer/src/services/bcf-server.ts b/apps/viewer/src/services/bcf-server.ts index dfbdbd6650..00dabf9e19 100644 --- a/apps/viewer/src/services/bcf-server.ts +++ b/apps/viewer/src/services/bcf-server.ts @@ -328,61 +328,87 @@ export async function signInWithClientCredentials( return completeSignIn(baseUrl, token, { clientId, clientSecret }); } -/** Single-flight token refresh so concurrent requests share one round-trip. */ -let refreshInFlight: Promise | null = null; +/** + * Single-flight token refresh PER SERVER. One unkeyed slot let a client + * bound to server B join server A's in-flight refresh and send A's bearer + * token to B's host; keying by serverUrl keeps each server's refresh (and + * its token) to clients of that server while still deduplicating + * concurrent refreshes against the same server. + */ +const refreshInFlight = new Map>(); /** Whether a stored connection has any material to re-authenticate with. */ function canReauthenticate(config: BcfServerConfig): boolean { return config.refreshToken.length > 0 || (config.clientId.length > 0 && config.clientSecret.length > 0); } +/** + * Whether the stored connection is still the SESSION this refresh started + * from. Server URL alone is not enough: signing in as a different account + * on the same server would otherwise get the previous account's refreshed + * tokens written into its record. The refresh material must be unchanged + * too, so a re-login as the same user (new refresh token) is not clobbered + * by the older session's rotation. + */ +function isSameSession(current: BcfServerConfig, config: BcfServerConfig): boolean { + return ( + current.serverUrl === config.serverUrl && + current.userId === config.userId && + current.refreshToken === config.refreshToken && + current.clientId === config.clientId + ); +} + async function refreshStoredToken(config: BcfServerConfig): Promise { - if (!refreshInFlight) { - refreshInFlight = (async () => { - const api = await loadApi(); - const anonymous = new api.BcfApiClient({ baseUrl: config.serverUrl }); - const authInfo = await anonymous.getAuthInfo(); - if (!canReauthenticate(config)) { - throw new api.BcfAuthenticationError('Session expired — sign in again', { - status: 401, - url: config.serverUrl, - }); - } - const tokenUrl = requireSecureTokenUrl(authInfo.oauth2_token_url); - // OAuth-app sessions must present the app credentials on the refresh - // grant too; token servers that never issued a client ignore them. - const token = config.refreshToken - ? await api.refreshAccessToken({ - tokenUrl, - refreshToken: config.refreshToken, - clientId: config.clientId || undefined, - clientSecret: config.clientSecret || undefined, - }) - : await api.requestClientCredentialsToken({ - tokenUrl, - clientId: config.clientId, - clientSecret: config.clientSecret, - }); - // Persist only when the stored connection is still THIS one. If the - // user disconnected (sign-out is their revocation gesture) or switched - // servers while the refresh was in flight, re-saving would resurrect - // the session or write this server's tokens under the other server's - // URL. The in-flight caller still gets the fresh token either way. - const current = loadBcfServerConfig(); - if (current && current.serverUrl === config.serverUrl) { - saveBcfServerConfig({ - ...current, - accessToken: token.access_token, - refreshToken: token.refresh_token ?? config.refreshToken, - tokenExpiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : 0, + const pending = refreshInFlight.get(config.serverUrl); + if (pending) return pending; + const started = (async () => { + const api = await loadApi(); + // Fail before the discovery round-trip when there is nothing to + // re-authenticate with. + if (!canReauthenticate(config)) { + throw new api.BcfAuthenticationError('Session expired — sign in again', { + status: 401, + url: config.serverUrl, + }); + } + const anonymous = new api.BcfApiClient({ baseUrl: config.serverUrl }); + const authInfo = await anonymous.getAuthInfo(); + const tokenUrl = requireSecureTokenUrl(authInfo.oauth2_token_url); + // OAuth-app sessions must present the app credentials on the refresh + // grant too; token servers that never issued a client ignore them. + const token = config.refreshToken + ? await api.refreshAccessToken({ + tokenUrl, + refreshToken: config.refreshToken, + clientId: config.clientId || undefined, + clientSecret: config.clientSecret || undefined, + }) + : await api.requestClientCredentialsToken({ + tokenUrl, + clientId: config.clientId, + clientSecret: config.clientSecret, }); - } - return token.access_token; - })().finally(() => { - refreshInFlight = null; - }); - } - return refreshInFlight; + // Persist only when the stored connection is still THIS session. If the + // user disconnected (sign-out is their revocation gesture), switched + // servers, or switched accounts while the refresh was in flight, + // re-saving would resurrect or hijack the replacement session. The + // in-flight caller still gets the fresh token either way. + const current = loadBcfServerConfig(); + if (current && isSameSession(current, config)) { + saveBcfServerConfig({ + ...current, + accessToken: token.access_token, + refreshToken: token.refresh_token ?? config.refreshToken, + tokenExpiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : 0, + }); + } + return token.access_token; + })().finally(() => { + refreshInFlight.delete(config.serverUrl); + }); + refreshInFlight.set(config.serverUrl, started); + return started; } /** diff --git a/docs/guide/bcf.md b/docs/guide/bcf.md index d7bc45b808..b1d7129e0b 100644 --- a/docs/guide/bcf.md +++ b/docs/guide/bcf.md @@ -194,14 +194,14 @@ const { project, warnings } = await fetchProjectAsBCF(client, projects[0].projec console.log(`Pulled ${project.topics.size} topics (${warnings.length} warnings)`); ``` -The client implements the BCF API 2.1 routes (projects, extensions, topics with OData paging, comments, viewpoints, component subresources, snapshots). Per-item failures — a missing snapshot, an unreadable components resource — degrade to `warnings` entries instead of failing the pull. +The client implements the BCF API 2.1 routes (projects, extensions, topics with OData paging, comments, viewpoints, component subresources, snapshots). Non-authentication per-item failures — a missing snapshot, an unreadable components resource — degrade to `warnings` entries; authentication failures (401) and an unreachable topics collection reject the whole pull. ## Viewer Integration In the IFClite viewer, BCF is integrated through the BCF panel: 1. **Load BCF** - Drag and drop a `.bcf` or `.bcfzip` file or use the BCF panel to import -2. **Connect to a BCF server** - The cloud button in the panel header connects to a BCF API server — pick a known server (Aconex regions, BIMcollab, BIMData.io, BIM Track/Newforma Konekt, Catenda Hub, Dalux Field, OpenProject, StreamBIM) or enter a custom URL, sign in with email & password, a pasted access token, or OAuth client credentials — then list its projects and load topics, viewpoints, and snapshots straight into the panel +2. **Connect to a BCF server** - The cloud button in the panel header connects to a BCF API server — pick a known server (Aconex regions, BIMcollab, BIMData.io, BIM Track/Newforma Konekt, Catenda Hub, Dalux Field, OpenProject, StreamBIM) or enter a custom URL, sign in via the browser OAuth popup (authorization code + PKCE, with dynamic client registration where the server offers it), email & password, a pasted access token, or OAuth client credentials — then list its projects and load topics, viewpoints, and snapshots straight into the panel 3. **Browse Topics** - View all issues with status, priority, and labels 4. **Navigate Viewpoints** - Click a viewpoint to restore camera and visibility 5. **Add Comments** - Discuss issues directly in the viewer diff --git a/packages/bcf-api/README.md b/packages/bcf-api/README.md index 4fdf7143ad..6879504c4a 100644 --- a/packages/bcf-api/README.md +++ b/packages/bcf-api/README.md @@ -41,7 +41,7 @@ const { project, warnings } = await fetchProjectAsBCF(client, projects[0].projec // (cameras, selection, coloring, visibility) and snapshots as data URLs. ``` -`fetchProjectAsBCF` pages the topics collection (`$top`/`$skip`), fetches each topic's comments and viewpoints concurrently, resolves viewpoint components (inline or via the `/selection`, `/coloring`, `/visibility` subresources), and downloads snapshots. Per-item failures degrade to entries in `warnings` instead of failing the pull. +`fetchProjectAsBCF` pages the topics collection (`$top`/`$skip`), fetches each topic's comments and viewpoints concurrently, resolves viewpoint components (inline or via the `/selection`, `/coloring`, `/visibility` subresources), and downloads snapshots. Non-authentication per-item failures (one topic's details, a components resource, a snapshot) degrade to entries in `warnings`; authentication failures (401) and an unreachable topics collection reject the whole pull. ## Direct endpoint access @@ -61,7 +61,7 @@ Errors are `BcfApiError` (with `status`, `url` and `isAuthError`); token endpoin ## Other auth flows -Not every server offers the password grant. `requestClientCredentialsToken({ tokenUrl, clientId, clientSecret })` covers servers with OAuth application credentials (e.g. OpenProject), and a token obtained elsewhere (a server's own UI, an authorization-code flow you run yourself) plugs straight into `getAccessToken`. +Not every server offers the password grant. For the browser authorization-code flow every BCF vendor advertises, `exchangeAuthorizationCode({ tokenUrl, code, redirectUri, codeVerifier?, clientId, clientSecret? })` completes a sign-in whose authorization step you drive yourself (PKCE supported), and `registerBcfClient({ registrationUrl, clientName, redirectUrl })` mints a client on servers that advertise dynamic client registration. `requestClientCredentialsToken({ tokenUrl, clientId, clientSecret })` covers servers with OAuth application credentials (e.g. OpenProject), and a token obtained elsewhere plugs straight into `getAccessToken`. ## License diff --git a/packages/bcf-api/src/auth.ts b/packages/bcf-api/src/auth.ts index 43e70d7010..4f7a12875e 100644 --- a/packages/bcf-api/src/auth.ts +++ b/packages/bcf-api/src/auth.ts @@ -56,7 +56,10 @@ export interface RegisteredClient { function resolveFetch(fetchFn: FetchLike | undefined): FetchLike { if (fetchFn) return fetchFn; - if (typeof fetch === 'function') return fetch; + // Wrapped, not returned bare: browsers brand-check fetch's receiver, so a + // detached reference can throw "Illegal invocation" (same guard as + // BcfApiClient's constructor). + if (typeof fetch === 'function') return (input, init) => fetch(input, init); throw new Error('No fetch implementation available; pass fetchFn explicitly.'); } diff --git a/packages/bcf-api/src/sync.test.ts b/packages/bcf-api/src/sync.test.ts index 21ea5f9c7c..62ad3930a8 100644 --- a/packages/bcf-api/src/sync.test.ts +++ b/packages/bcf-api/src/sync.test.ts @@ -13,8 +13,12 @@ interface FakeServerOptions { ignorePaging?: boolean; /** Viewpoint guids whose snapshot endpoint should fail. */ brokenSnapshots?: string[]; + /** HTTP status for broken snapshots; defaults to 404. */ + brokenSnapshotStatus?: number; /** Topic guids whose /comments endpoint fails with this HTTP status. */ brokenComments?: { guids: string[]; status: number }; + /** When set, every /selection subresource fails with this HTTP status. */ + brokenSelectionStatus?: number; } /** Minimal in-memory BCF 2.1 server: one project, one viewpoint per topic. */ @@ -59,13 +63,18 @@ function fakeBcfServer(options: FakeServerOptions): FetchLike { }, ]); } - if (path.endsWith('/selection')) return json({ selection: [{ ifc_guid: 'guid_selection_000000A' }] }); + if (path.endsWith('/selection')) { + if (options.brokenSelectionStatus) { + return json({ message: 'selection broken' }, options.brokenSelectionStatus); + } + return json({ selection: [{ ifc_guid: 'guid_selection_000000A' }] }); + } if (path.endsWith('/coloring')) return json({ coloring: [] }); if (path.endsWith('/visibility')) return json({ visibility: { default_visibility: true } }); if (path.endsWith('/snapshot')) { const viewpointGuid = /viewpoints\/([^/]+)\/snapshot$/.exec(path)?.[1]; if (viewpointGuid && options.brokenSnapshots?.includes(viewpointGuid)) { - return json({ message: 'snapshot missing' }, 404); + return json({ message: 'snapshot missing' }, options.brokenSnapshotStatus ?? 404); } return new Response(new Uint8Array([1, 2, 3]), { status: 200, @@ -208,6 +217,47 @@ describe('fetchProjectAsBCF', () => { ).rejects.toMatchObject({ status: 401 }); }); + it('fails the whole pull when viewpoint components fail with 401, but warns on a 500', async () => { + // 401 anywhere means the session died; only genuine per-item failures warn. + await expect( + fetchProjectAsBCF( + makeClient(fakeBcfServer({ topics: makeTopics(1), brokenSelectionStatus: 401 })), + 'p1', + { includeSnapshots: false }, + ), + ).rejects.toMatchObject({ status: 401 }); + const { warnings } = await fetchProjectAsBCF( + makeClient(fakeBcfServer({ topics: makeTopics(1), brokenSelectionStatus: 500 })), + 'p1', + { includeSnapshots: false }, + ); + expect(warnings.some((w) => w.includes('Components unavailable'))).toBe(true); + }); + + it('fails the whole pull when a snapshot fails with 401 (unlike a missing snapshot)', async () => { + await expect( + fetchProjectAsBCF( + makeClient( + fakeBcfServer({ + topics: makeTopics(1), + brokenSnapshots: ['vp-topic-0'], + brokenSnapshotStatus: 401, + }), + ), + 'p1', + ), + ).rejects.toMatchObject({ status: 401 }); + }); + + it('floors a fractional maxTopics instead of admitting an extra topic', async () => { + const client = makeClient(fakeBcfServer({ topics: makeTopics(5) })); + const { project } = await fetchProjectAsBCF(client, 'p1', { + maxTopics: 1.5, + includeSnapshots: false, + }); + expect(project.topics.size).toBe(1); + }); + it('degrades a failed snapshot to a warning and keeps the viewpoint', async () => { const client = makeClient( fakeBcfServer({ topics: makeTopics(1), brokenSnapshots: ['vp-topic-0'] }), diff --git a/packages/bcf-api/src/sync.ts b/packages/bcf-api/src/sync.ts index 5c89bdb008..0884d2b384 100644 --- a/packages/bcf-api/src/sync.ts +++ b/packages/bcf-api/src/sync.ts @@ -89,8 +89,10 @@ async function fetchAllTopics( options: FetchProjectOptions, warnings: string[], ): Promise { - const pageSize = options.pageSize ?? 100; - const maxTopics = options.maxTopics ?? 1000; + // Normalize caller-supplied sizes: a fractional maxTopics would let + // `topics.length < maxTopics` admit one topic past the documented cap. + const pageSize = Math.max(1, Math.floor(options.pageSize ?? 100)); + const maxTopics = Math.max(0, Math.floor(options.maxTopics ?? 1000)); const topics: BcfTopicDto[] = []; const seen = new Set(); let skippedNoGuid = 0; @@ -162,6 +164,9 @@ async function fetchViewpointComponents( visibility: visibility.visibility, }; } catch (error) { + // An expired session is fatal everywhere (matching fetchTopicDetails); + // only genuine per-item failures degrade to warnings. + if (error instanceof BcfApiError && error.isAuthError) throw error; warnings.push( `Components unavailable for viewpoint ${viewpoint.guid}: ${error instanceof Error ? error.message : String(error)}`, ); @@ -209,6 +214,7 @@ async function fetchTopicDetails( const blob = await client.getViewpointSnapshot(projectId, dto.guid, viewpointDto.guid); viewpoint.snapshot = await blobToDataUrl(blob); } catch (error) { + if (error instanceof BcfApiError && error.isAuthError) throw error; warnings.push( `Snapshot unavailable for viewpoint ${viewpointDto.guid}: ${error instanceof Error ? error.message : String(error)}`, ); From 61996a9f826076eaeea841f39774065006d58697 Mon Sep 17 00:00:00 2001 From: Jonatan Jacobsson Date: Wed, 26 Aug 2026 06:36:11 +0200 Subject: [PATCH 3/3] fix(bcf-api,viewer): second-round review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-flight refresh map is now keyed by full session identity (server + user + client + refresh material, NUL-joined), not serverUrl alone — an account signing in on the same server could otherwise join the previous account's pending refresh and run its next request under that account's token. Viewpoint component subresources use Promise.allSettled so a fast non-auth failure cannot mask a concurrent 401 (with a mixed 500+401 regression test). Docs qualify the authorization-code flow by the server's advertised supported_oauth2_flows instead of claiming it universally, and a warning assertion checks count rather than message text. Co-Authored-By: Claude Fable 5 --- apps/viewer/src/services/bcf-server.ts | 24 ++++++++++---- packages/bcf-api/README.md | 2 +- packages/bcf-api/src/auth.ts | 4 +-- packages/bcf-api/src/sync.test.ts | 29 +++++++++++++++-- packages/bcf-api/src/sync.ts | 44 ++++++++++++++++---------- 5 files changed, 75 insertions(+), 28 deletions(-) diff --git a/apps/viewer/src/services/bcf-server.ts b/apps/viewer/src/services/bcf-server.ts index 00dabf9e19..a0dd8be1f8 100644 --- a/apps/viewer/src/services/bcf-server.ts +++ b/apps/viewer/src/services/bcf-server.ts @@ -329,14 +329,23 @@ export async function signInWithClientCredentials( } /** - * Single-flight token refresh PER SERVER. One unkeyed slot let a client + * Single-flight token refresh PER SESSION. One unkeyed slot let a client * bound to server B join server A's in-flight refresh and send A's bearer - * token to B's host; keying by serverUrl keeps each server's refresh (and - * its token) to clients of that server while still deduplicating - * concurrent refreshes against the same server. + * token to B's host — and keying by server alone would still let account + * B's request on the same server join account A's refresh and run under + * A's token. Keying by the full session identity keeps every refresh (and + * its token) to the session that started it while still deduplicating + * concurrent refreshes of the same session. */ const refreshInFlight = new Map>(); +/** Identity of the session a refresh belongs to, not just its server. */ +function refreshSessionKey(config: BcfServerConfig): string { + // The NUL escape below cannot appear in a URL, user id, client id, or + // token, so joined parts cannot collide across field boundaries. + return [config.serverUrl, config.userId, config.clientId, config.refreshToken].join('\u0000'); +} + /** Whether a stored connection has any material to re-authenticate with. */ function canReauthenticate(config: BcfServerConfig): boolean { return config.refreshToken.length > 0 || (config.clientId.length > 0 && config.clientSecret.length > 0); @@ -360,7 +369,8 @@ function isSameSession(current: BcfServerConfig, config: BcfServerConfig): boole } async function refreshStoredToken(config: BcfServerConfig): Promise { - const pending = refreshInFlight.get(config.serverUrl); + const key = refreshSessionKey(config); + const pending = refreshInFlight.get(key); if (pending) return pending; const started = (async () => { const api = await loadApi(); @@ -405,9 +415,9 @@ async function refreshStoredToken(config: BcfServerConfig): Promise { } return token.access_token; })().finally(() => { - refreshInFlight.delete(config.serverUrl); + refreshInFlight.delete(key); }); - refreshInFlight.set(config.serverUrl, started); + refreshInFlight.set(key, started); return started; } diff --git a/packages/bcf-api/README.md b/packages/bcf-api/README.md index 6879504c4a..eabdd9c09c 100644 --- a/packages/bcf-api/README.md +++ b/packages/bcf-api/README.md @@ -61,7 +61,7 @@ Errors are `BcfApiError` (with `status`, `url` and `isAuthError`); token endpoin ## Other auth flows -Not every server offers the password grant. For the browser authorization-code flow every BCF vendor advertises, `exchangeAuthorizationCode({ tokenUrl, code, redirectUri, codeVerifier?, clientId, clientSecret? })` completes a sign-in whose authorization step you drive yourself (PKCE supported), and `registerBcfClient({ registrationUrl, clientName, redirectUrl })` mints a client on servers that advertise dynamic client registration. `requestClientCredentialsToken({ tokenUrl, clientId, clientSecret })` covers servers with OAuth application credentials (e.g. OpenProject), and a token obtained elsewhere plugs straight into `getAccessToken`. +Not every server offers the password grant — each advertises its flows in `/auth`'s `supported_oauth2_flows`. For servers that support the authorization-code flow, `exchangeAuthorizationCode({ tokenUrl, code, redirectUri, codeVerifier?, clientId, clientSecret? })` completes a sign-in whose authorization step you drive yourself (PKCE supported), and `registerBcfClient({ registrationUrl, clientName, redirectUrl })` mints a client on servers that advertise dynamic client registration. `requestClientCredentialsToken({ tokenUrl, clientId, clientSecret })` covers servers with OAuth application credentials (e.g. OpenProject), and a token obtained elsewhere plugs straight into `getAccessToken`. ## License diff --git a/packages/bcf-api/src/auth.ts b/packages/bcf-api/src/auth.ts index 4f7a12875e..6ddeeabb16 100644 --- a/packages/bcf-api/src/auth.ts +++ b/packages/bcf-api/src/auth.ts @@ -162,8 +162,8 @@ export function requestClientCredentialsToken( /** * OAuth2 authorization-code exchange (RFC 6749 §4.1.3), the second half of - * the browser sign-in every BCF vendor advertises - * (`authorization_code_grant`). Sends the PKCE verifier when one is given; + * the browser sign-in on servers whose `/auth` discovery advertises + * `authorization_code_grant`. Sends the PKCE verifier when one is given; * servers that never saw a challenge ignore it. */ export function exchangeAuthorizationCode( diff --git a/packages/bcf-api/src/sync.test.ts b/packages/bcf-api/src/sync.test.ts index 62ad3930a8..ea23fd6344 100644 --- a/packages/bcf-api/src/sync.test.ts +++ b/packages/bcf-api/src/sync.test.ts @@ -19,6 +19,8 @@ interface FakeServerOptions { brokenComments?: { guids: string[]; status: number }; /** When set, every /selection subresource fails with this HTTP status. */ brokenSelectionStatus?: number; + /** When set, every /coloring subresource fails with this HTTP status. */ + brokenColoringStatus?: number; } /** Minimal in-memory BCF 2.1 server: one project, one viewpoint per topic. */ @@ -69,7 +71,12 @@ function fakeBcfServer(options: FakeServerOptions): FetchLike { } return json({ selection: [{ ifc_guid: 'guid_selection_000000A' }] }); } - if (path.endsWith('/coloring')) return json({ coloring: [] }); + if (path.endsWith('/coloring')) { + if (options.brokenColoringStatus) { + return json({ message: 'coloring broken' }, options.brokenColoringStatus); + } + return json({ coloring: [] }); + } if (path.endsWith('/visibility')) return json({ visibility: { default_visibility: true } }); if (path.endsWith('/snapshot')) { const viewpointGuid = /viewpoints\/([^/]+)\/snapshot$/.exec(path)?.[1]; @@ -231,7 +238,25 @@ describe('fetchProjectAsBCF', () => { 'p1', { includeSnapshots: false }, ); - expect(warnings.some((w) => w.includes('Components unavailable'))).toBe(true); + expect(warnings).toHaveLength(1); + }); + + it('does not let a concurrent 500 mask a 401 among the component subresources', async () => { + // With Promise.all, whichever rejection settles first would be the only + // one seen; a fast 500 could bury the session-expiry 401. + await expect( + fetchProjectAsBCF( + makeClient( + fakeBcfServer({ + topics: makeTopics(1), + brokenSelectionStatus: 500, + brokenColoringStatus: 401, + }), + ), + 'p1', + { includeSnapshots: false }, + ), + ).rejects.toMatchObject({ status: 401 }); }); it('fails the whole pull when a snapshot fails with 401 (unlike a missing snapshot)', async () => { diff --git a/packages/bcf-api/src/sync.ts b/packages/bcf-api/src/sync.ts index 0884d2b384..452c5e929f 100644 --- a/packages/bcf-api/src/sync.ts +++ b/packages/bcf-api/src/sync.ts @@ -152,26 +152,38 @@ async function fetchViewpointComponents( if (viewpoint.components && componentsFromApi(viewpoint.components)) { return viewpoint.components; } - try { - const [selection, coloring, visibility] = await Promise.all([ - client.getViewpointSelection(projectId, topicGuid, viewpoint.guid), - client.getViewpointColoring(projectId, topicGuid, viewpoint.guid), - client.getViewpointVisibility(projectId, topicGuid, viewpoint.guid), - ]); - return { - selection: selection.selection, - coloring: coloring.coloring, - visibility: visibility.visibility, - }; - } catch (error) { - // An expired session is fatal everywhere (matching fetchTopicDetails); - // only genuine per-item failures degrade to warnings. - if (error instanceof BcfApiError && error.isAuthError) throw error; + // allSettled rather than all: with all, a fast non-auth failure would be + // the only rejection this code ever sees, masking a concurrent 401 — and + // an expired session is fatal everywhere (matching fetchTopicDetails), + // while only genuine per-item failures degrade to warnings. + const [selectionResult, coloringResult, visibilityResult] = await Promise.allSettled([ + client.getViewpointSelection(projectId, topicGuid, viewpoint.guid), + client.getViewpointColoring(projectId, topicGuid, viewpoint.guid), + client.getViewpointVisibility(projectId, topicGuid, viewpoint.guid), + ]); + if ( + selectionResult.status === 'rejected' || + coloringResult.status === 'rejected' || + visibilityResult.status === 'rejected' + ) { + const reasons = [selectionResult, coloringResult, visibilityResult] + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason as unknown); + const authFailure = reasons.find( + (reason) => reason instanceof BcfApiError && reason.isAuthError, + ); + if (authFailure) throw authFailure; + const first = reasons[0]; warnings.push( - `Components unavailable for viewpoint ${viewpoint.guid}: ${error instanceof Error ? error.message : String(error)}`, + `Components unavailable for viewpoint ${viewpoint.guid}: ${first instanceof Error ? first.message : String(first)}`, ); return undefined; } + return { + selection: selectionResult.value.selection, + coloring: coloringResult.value.coloring, + visibility: visibilityResult.value.visibility, + }; } async function fetchTopicDetails(