diff --git a/apps/dfm/README.md b/apps/dfm/README.md index 5ef58c1..bc69194 100644 --- a/apps/dfm/README.md +++ b/apps/dfm/README.md @@ -1,7 +1,8 @@ # DFM -A public, bring-your-own-key reference application for inspecting Toolpath Engine part features -and meshes. It keeps the `@toolpath/api` workflow visible and delegates HTTP, +A public reference application for inspecting Toolpath Engine part features and meshes. It tries a +short-lived shared demo key first and falls back to bring-your-own-key when a demo key is unavailable. +It keeps the `@toolpath/api` workflow visible and delegates HTTP, validation, sessions, and SSE plumbing to Hono while React renders a conventional SPA. SSE monitors the part-analysis job's queued and running progress until it succeeds or fails without polling. @@ -21,8 +22,8 @@ to `https://api.toolpath.com`; change it in `apps/dfm/.env` when using another E - `app/` is a client-rendered React SPA. It calls only app-owned `/api/*` routes; it never receives the API key or raw artifact URLs. -- `server/` is Hono-only. It serves the built SPA, seals the BYOK connection cookie with `jose`, - validates requests with Zod, and is the sole location that uses the Toolpath SDK. +- `server/` is Hono-only. It serves the built SPA, seals demo and BYOK connection cookies with + `jose`, validates requests with Zod, and is the sole location that uses the Toolpath SDK. - `server/routes/parts.ts` is the core SDK example: it creates a part through the SDK, returns its short-lived presigned PUT URL, then starts analysis through the SDK. The browser uploads the CAD file directly to object storage; The server never receives or buffers CAD bytes. @@ -34,9 +35,11 @@ to `https://api.toolpath.com`; change it in `apps/dfm/.env` when using another E ## Request flow 1. The SPA calls `GET /api/session` when it starts. Hono reads the encrypted `HttpOnly` cookie and - returns only whether a connection exists. -2. `POST /api/session` seals a submitted API key in an encrypted, eight-hour `HttpOnly`, `Secure`, - `SameSite=Lax` cookie. + returns whether a connection exists plus its non-secret demo/BYOK kind. If it is disconnected, the SPA calls + `POST /api/session/demo`; Hono requests a short-lived demo key from `POST /v1/demo/session` and + reports whether one was available. +2. `POST /api/session/demo` or `POST /api/session` seals the received demo or submitted API key in + an encrypted, eight-hour `HttpOnly`, `Secure`, `SameSite=Lax` cookie. 3. `POST /api/parts` calls `POST /v1/parts?filename=...` and returns its short-lived, single-object PUT URL. The browser uploads directly to that URL, then `PATCH /api/parts/:partId?featureDetails=true` calls diff --git a/apps/dfm/app/client/api.test.ts b/apps/dfm/app/client/api.test.ts index 2d14089..5769aed 100644 --- a/apps/dfm/app/client/api.test.ts +++ b/apps/dfm/app/client/api.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest' -import { getSession, uploadPart } from './api' +import { getSession, startDemoSession, uploadPart } from './api' afterEach(() => vi.unstubAllGlobals()) @@ -13,6 +13,17 @@ describe('direct CAD upload', () => { await expect(getSession()).resolves.toEqual({ connected: false }) }) + test('asks the server for a demo session', async () => { + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(new URL(String(input), 'http://part-viewer.test'), init) + expect(request.method).toBe('POST') + expect(request.url).toBe('http://part-viewer.test/api/session/demo') + return Response.json({ connected: true }) + }) + + await expect(startDemoSession()).resolves.toEqual({ connected: true }) + }) + test('creates a part, PUTs the file directly, then starts analysis', async () => { const requests: Array = [] const phases: Array = [] diff --git a/apps/dfm/app/client/api.ts b/apps/dfm/app/client/api.ts index 7e2d677..aec39c3 100644 --- a/apps/dfm/app/client/api.ts +++ b/apps/dfm/app/client/api.ts @@ -13,6 +13,11 @@ export class AppApiError extends Error { export type PartUploadPhase = 'creating-part' | 'uploading-file' | 'starting-analysis' +export interface SessionState { + connected: boolean + isDemo?: boolean +} + export interface UploadPartOptions { onPhaseChange?: (phase: PartUploadPhase) => void } @@ -28,15 +33,21 @@ const api = async (path: string, init?: RequestInit): Promise => { /** Reads only connection state; the encrypted API key stays in the HttpOnly cookie. */ export const getSession = () => - api<{ connected: boolean }>('/api/session', { signal: AbortSignal.timeout(5_000) }) + api('/api/session', { signal: AbortSignal.timeout(5_000) }) export const connect = (apiKey: string) => - api<{ connected: true }>('/api/session', { + api('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey }), }) +/** + * Asks the server to connect with a shared demo key. Reports whether one was + * available; when it is not, the caller keeps asking for the user's own key. + */ +export const startDemoSession = () => api('/api/session/demo', { method: 'POST' }) + export const disconnect = () => api('/api/session', { method: 'DELETE' }) const uploadToEngine = async (file: File, uploadUrl: string): Promise => { diff --git a/apps/dfm/app/client/use-session.ts b/apps/dfm/app/client/use-session.ts index 546bf98..6487e7c 100644 --- a/apps/dfm/app/client/use-session.ts +++ b/apps/dfm/app/client/use-session.ts @@ -1,28 +1,68 @@ import { useCallback, useEffect, useState } from 'react' -import { connect, disconnect, getSession } from './api' +import { connect, disconnect, getSession, startDemoSession } from './api' import { errorMessage } from './error-message' export type SessionStatus = 'checking' | 'disconnected' | 'connected' export type SessionAction = 'idle' | 'connecting' | 'disconnecting' +export interface UseSessionOptions { + /** + * When the browser has no connection, try for a shared demo key before + * settling on `disconnected`. Off by default; an application that wants a + * key-free trial turns it on. Demo keys are only sometimes available, so a + * failure here silently leaves the session disconnected and the manual + * key form in place. + */ + demoFallback?: boolean +} + /** Owns the browser-visible session state; the API key itself always remains server-only. */ -export const useSession = () => { +export const useSession = ({ demoFallback = false }: UseSessionOptions = {}) => { const [status, setStatus] = useState('checking') const [action, setAction] = useState('idle') const [error, setError] = useState(null) + const [isDemo, setIsDemo] = useState(false) useEffect(() => { - void getSession() - .then(({ connected }) => setStatus(connected ? 'connected' : 'disconnected')) - .catch(() => setStatus('disconnected')) - }, []) + let cancelled = false + const settle = ({ + connected, + isDemo: isDemoSession = false, + }: { + connected: boolean + isDemo?: boolean + }) => { + if (!cancelled) { + setStatus(connected ? 'connected' : 'disconnected') + setIsDemo(connected && isDemoSession) + } + } + void (async () => { + try { + const session = await getSession() + const { connected } = session + if (connected || !demoFallback) { + settle(session) + return + } + const demo = await startDemoSession().catch(() => ({ connected: false })) + settle(demo) + } catch { + settle({ connected: false }) + } + })() + return () => { + cancelled = true + } + }, [demoFallback]) const connectWithKey = useCallback(async (apiKey: string) => { setAction('connecting') setError(null) try { - await connect(apiKey) + const session = await connect(apiKey) setStatus('connected') + setIsDemo(session.isDemo === true) } catch (reason) { setError(errorMessage(reason)) throw reason @@ -37,6 +77,7 @@ export const useSession = () => { try { await disconnect() setStatus('disconnected') + setIsDemo(false) } catch (reason) { setError(errorMessage(reason)) } finally { @@ -44,5 +85,5 @@ export const useSession = () => { } }, []) - return { status, action, error, connectWithKey, disconnectSession } + return { status, action, error, isDemo, connectWithKey, disconnectSession } } diff --git a/apps/dfm/app/components/upload-panel.tsx b/apps/dfm/app/components/upload-panel.tsx index 0224e89..ade28dc 100644 --- a/apps/dfm/app/components/upload-panel.tsx +++ b/apps/dfm/app/components/upload-panel.tsx @@ -22,12 +22,14 @@ export const UploadPanel = ({ onUpload, onDisconnect, isDisconnecting, + showDisconnect, }: { error: string | null status: UploadStatus onUpload: (file: File) => Promise onDisconnect: () => Promise isDisconnecting: boolean + showDisconnect: boolean }) => { const [file, setFile] = useState(null) const isUploading = status !== 'idle' @@ -71,15 +73,17 @@ export const UploadPanel = ({ {uploadLabel(status)} - + {showDisconnect ? ( + + ) : null} ) } diff --git a/apps/dfm/app/routes/home.tsx b/apps/dfm/app/routes/home.tsx index 9ded35c..16c6b21 100644 --- a/apps/dfm/app/routes/home.tsx +++ b/apps/dfm/app/routes/home.tsx @@ -7,7 +7,9 @@ import { useSession } from 'client/use-session' import { Card } from '@toolpath/ui' const HomeRoute = () => { - const session = useSession() + // Try a shared demo key first, so DFM can be used without one; the manual + // key form below is the fallback when no demo key is available. + const session = useSession({ demoFallback: true }) const partUpload = usePartUpload() return ( @@ -28,7 +30,9 @@ const HomeRoute = () => { {session.status === 'checking' ? ( -

Checking local session…

+ // A demo key is being tried; hold the page rather than flash the + // manual key form on the way to the uploader. +

Connecting…

) : session.status === 'connected' ? ( { onUpload={partUpload.upload} onDisconnect={session.disconnectSession} isDisconnecting={session.action === 'disconnecting'} + showDisconnect={!session.isDemo} /> ) : ( { expect(cookie).not.toContain('tp_secret_key') const status = await app.request('/api/session', { headers: { Cookie: cookie } }) - await expect(status.json()).resolves.toEqual({ connected: true }) + await expect(status.json()).resolves.toEqual({ connected: true, isDemo: false }) const cleared = await app.request('/api/session', { method: 'DELETE', headers: { Cookie: cookie, 'Sec-Fetch-Site': 'same-origin' }, @@ -161,6 +161,64 @@ describe('DFM Hono API', () => { expect(cleared.headers.getSetCookie()[0]).toContain('Max-Age=0') }) + test('seals a demo key from the Engine into a session', async () => { + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + expect(request.method).toBe('POST') + expect(new URL(request.url).pathname).toBe('/v1/demo/session') + return Response.json({ + apiKey: 'tp_demo_key', + expiresAt: '2026-09-26T13:48:03.172Z', + orgId: 'org-1', + }) + }) + const app = createApp() + const connected = await app.request('/api/session/demo', { + method: 'POST', + headers: { 'Sec-Fetch-Site': 'same-origin' }, + }) + + expect(connected.status).toBe(201) + await expect(connected.json()).resolves.toEqual({ connected: true, isDemo: true }) + const cookie = connected.headers.getSetCookie()[0] + expect(cookie).toContain('part-viewer-connection=') + expect(cookie).toContain('HttpOnly') + // The demo key is as private as any other; it never reaches the browser. + expect(cookie).not.toContain('tp_demo_key') + + const status = await app.request('/api/session', { headers: { Cookie: cookie } }) + await expect(status.json()).resolves.toEqual({ connected: true, isDemo: true }) + }) + + test('falls back to disconnected when no demo key is available', async () => { + vi.stubGlobal('fetch', async () => + Response.json({ error: 'demo_unavailable' }, { status: 503 }), + ) + const response = await createApp().request('/api/session/demo', { + method: 'POST', + headers: { 'Sec-Fetch-Site': 'same-origin' }, + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ connected: false }) + expect(response.headers.getSetCookie()).toEqual([]) + }) + + test('keeps an existing session rather than replacing it with a demo one', async () => { + const fetchSpy = vi.fn<() => Promise>() + vi.stubGlobal('fetch', fetchSpy) + const response = await createApp().request('/api/session/demo', { + method: 'POST', + headers: { Cookie: await cookieFor(), 'Sec-Fetch-Site': 'same-origin' }, + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ connected: true, isDemo: false }) + // A live session is not spent on a demo key it does not need. + expect(fetchSpy).not.toHaveBeenCalled() + expect(response.headers.getSetCookie()).toEqual([]) + }) + test('rejects an invalid API key without creating a session', async () => { vi.stubGlobal('fetch', async () => Response.json({ valid: false, status: 'revoked' }, { status: 401 }), diff --git a/apps/dfm/server/connection.ts b/apps/dfm/server/connection.ts index 70c544a..7a38202 100644 --- a/apps/dfm/server/connection.ts +++ b/apps/dfm/server/connection.ts @@ -34,9 +34,18 @@ const clearConnection = (c: Context): void => { deleteCookie(c, CONNECTION_COOKIE, cookieOptions) } -/** Encrypts the BYOK API key into an eight-hour HttpOnly connection cookie. */ -export const setConnection = async (c: Context, apiKey: string): Promise => { - const token = await new EncryptJWT({ apiKey }) +export interface Connection { + apiKey: string + isDemo: boolean +} + +/** Encrypts an API key and its non-secret connection kind into an eight-hour HttpOnly cookie. */ +export const setConnection = async ( + c: Context, + apiKey: string, + isDemo = false, +): Promise => { + const token = await new EncryptJWT({ apiKey, isDemo }) .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' }) .setIssuedAt() .setIssuer(ISSUER) @@ -47,10 +56,10 @@ export const setConnection = async (c: Context, apiKey: string): Promise } /** - * Returns the server-only API key for this request. Expired, tampered, or rotated-secret cookies + * Returns the server-only connection for this request. Expired, tampered, or rotated-secret cookies * are simply cleared: they are not application errors and never reach React. */ -export const readApiKey = async (c: Context): Promise => { +export const readConnection = async (c: Context): Promise => { const token = getCookie(c, CONNECTION_COOKIE) if (!token) { return null @@ -64,7 +73,9 @@ export const readApiKey = async (c: Context): Promise => contentEncryptionAlgorithms: ['A256GCM'], }) if (typeof payload.apiKey === 'string' && payload.apiKey) { - return payload.apiKey + // Cookies sealed before demo access existed have no kind; they were all + // BYOK connections, so retain the disconnect control for them. + return { apiKey: payload.apiKey, isDemo: payload.isDemo === true } } clearConnection(c) return null @@ -74,6 +85,9 @@ export const readApiKey = async (c: Context): Promise => } } +export const readApiKey = async (c: Context): Promise => + (await readConnection(c))?.apiKey ?? null + export { clearConnection } export const requireApiKey = async (c: Context): Promise => { diff --git a/apps/dfm/server/engine.ts b/apps/dfm/server/engine.ts index f755a96..455736f 100644 --- a/apps/dfm/server/engine.ts +++ b/apps/dfm/server/engine.ts @@ -88,6 +88,41 @@ const keyStatusFromResponse = async ( } } +/** + * Asks the Engine for a short-lived demo API key. + * + * Demo keys are only sometimes offered — the feature can be disabled, the pool + * exhausted, or the request can simply fail in transit. Every one of those is + * "no demo key today" rather than an error: this returns `null` and the caller + * falls back to asking for the user's own key. The key it does return is minted + * by the Engine, so it is sealed into the session without a second validation + * round trip. + */ +export const requestDemoSession = async (appName = 'part-viewer'): Promise => { + let response: Response + try { + response = await fetch(`${apiBaseUrl()}/v1/demo/session`, { + method: 'POST', + headers: { Accept: 'application/json' }, + }) + } catch (cause) { + console.error(`[${appName}] Demo session request failed`, { + engineUrl: apiBaseUrl(), + error: cause instanceof Error ? cause.message : String(cause), + }) + return null + } + if (!response.ok) { + return null + } + try { + const body = (await response.json()) as { apiKey?: unknown } + return typeof body.apiKey === 'string' && body.apiKey ? body.apiKey : null + } catch { + return null + } +} + /** Confirms a submitted BYOK key before it is persisted in the encrypted browser session. */ export const validateApiKey = async (apiKey: string): Promise => { try { diff --git a/apps/dfm/server/routes/session.ts b/apps/dfm/server/routes/session.ts index 09d70b1..46deef8 100644 --- a/apps/dfm/server/routes/session.ts +++ b/apps/dfm/server/routes/session.ts @@ -1,14 +1,39 @@ import { zValidator } from '@hono/zod-validator' import type { Hono } from 'hono' import { z } from 'zod' -import { clearConnection, readApiKey, setConnection } from '../connection' -import { InvalidApiKeyError, validateApiKey } from '../engine' +import { clearConnection, readConnection, setConnection } from '../connection' +import { InvalidApiKeyError, requestDemoSession, validateApiKey } from '../engine' import type { AppEnv } from '../types' const connectSchema = z.object({ apiKey: z.string().trim().min(1, 'Enter an API key to connect.') }) export const registerSessionRoutes = (app: Hono) => { - app.get('/api/session', async (c) => c.json({ connected: Boolean(await readApiKey(c)) })) + app.get('/api/session', async (c) => { + const connection = await readConnection(c) + return c.json( + connection ? { connected: true, isDemo: connection.isDemo } : { connected: false }, + ) + }) + + /** + * Connects with a shared demo key when the Engine offers one, so an + * application can be tried without a key of its own. A demo key is only + * sometimes available; when it is not, this reports `connected: false` and the + * application falls back to asking for the user's own key. An existing session + * is left as it is rather than replaced with a demo one. + */ + app.post('/api/session/demo', async (c) => { + const existing = await readConnection(c) + if (existing) { + return c.json({ connected: true, isDemo: existing.isDemo }) + } + const apiKey = await requestDemoSession() + if (!apiKey) { + return c.json({ connected: false }) + } + await setConnection(c, apiKey, true) + return c.json({ connected: true, isDemo: true }, 201) + }) app.post('/api/session', zValidator('json', connectSchema), async (c) => { const { apiKey } = c.req.valid('json') @@ -27,7 +52,7 @@ export const registerSessionRoutes = (app: Hono) => { throw error } await setConnection(c, apiKey) - return c.json({ connected: true }, 201) + return c.json({ connected: true, isDemo: false }, 201) }) app.delete('/api/session', (c) => { diff --git a/apps/dfm/tests/dfm.spec.ts b/apps/dfm/tests/dfm.spec.ts index 2e97260..328cbe2 100644 --- a/apps/dfm/tests/dfm.spec.ts +++ b/apps/dfm/tests/dfm.spec.ts @@ -39,3 +39,7 @@ test('connects, uploads, opens a redacted inspector, and focuses a feature', asy await page.getByRole('link', { name: 'Upload another part' }).click() await expect(page.getByLabel('CAD file')).toBeVisible() }) + +test('uses a demo key without offering its disconnect control', async ({ page }) => { + await uploadTo(page, part, { demo: true }) +}) diff --git a/apps/dfm/tests/part-fixture.ts b/apps/dfm/tests/part-fixture.ts index 576c5d7..956f3f5 100644 --- a/apps/dfm/tests/part-fixture.ts +++ b/apps/dfm/tests/part-fixture.ts @@ -239,7 +239,7 @@ export const richHole = ( export const uploadTo = async ( page: Page, part: unknown, - { key = 'tp_key' }: { key?: string } = {}, + { key = 'tp_key', demo = false }: { key?: string; demo?: boolean } = {}, ): Promise => { let connected = false @@ -247,6 +247,13 @@ export const uploadTo = async ( const request = route.request() const url = new URL(request.url()) + if (url.pathname === '/api/session/demo') { + if (demo) { + connected = true + return route.fulfill({ status: 201, json: { connected: true, isDemo: true } }) + } + return route.fulfill({ json: { connected: false } }) + } if (url.pathname === '/api/session') { if (request.method() === 'GET') { return route.fulfill({ json: { connected } }) @@ -281,9 +288,16 @@ export const uploadTo = async ( await page.route('https://upload.test/source', (route) => route.fulfill({ status: 200 })) await page.goto('/') - await page.getByLabel('Toolpath Engine API key').fill(key) - await page.getByRole('button', { name: 'Connect' }).click() + if (!demo) { + await page.getByLabel('Toolpath Engine API key').fill(key) + await page.getByRole('button', { name: 'Connect' }).click() + } await expect(page.getByLabel('CAD file')).toBeVisible() + if (demo) { + await expect(page.getByRole('button', { name: 'Disconnect API key' })).toHaveCount(0) + } else { + await expect(page.getByRole('button', { name: 'Disconnect API key' })).toBeVisible() + } await page.getByLabel('CAD file').setInputFiles({ name: 'fixture.step',