Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions apps/dfm/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion apps/dfm/app/client/api.test.ts
Original file line number Diff line number Diff line change
@@ -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())

Expand All @@ -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<Request> = []
const phases: Array<string> = []
Expand Down
15 changes: 13 additions & 2 deletions apps/dfm/app/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -28,15 +33,21 @@ const api = async <T>(path: string, init?: RequestInit): Promise<T> => {

/** 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<SessionState>('/api/session', { signal: AbortSignal.timeout(5_000) })

export const connect = (apiKey: string) =>
api<{ connected: true }>('/api/session', {
api<SessionState>('/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<SessionState>('/api/session/demo', { method: 'POST' })

export const disconnect = () => api<void>('/api/session', { method: 'DELETE' })

const uploadToEngine = async (file: File, uploadUrl: string): Promise<void> => {
Expand Down
57 changes: 49 additions & 8 deletions apps/dfm/app/client/use-session.ts
Original file line number Diff line number Diff line change
@@ -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<SessionStatus>('checking')
const [action, setAction] = useState<SessionAction>('idle')
const [error, setError] = useState<string | null>(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
Expand All @@ -37,12 +77,13 @@ export const useSession = () => {
try {
await disconnect()
setStatus('disconnected')
setIsDemo(false)
} catch (reason) {
setError(errorMessage(reason))
} finally {
setAction('idle')
}
}, [])

return { status, action, error, connectWithKey, disconnectSession }
return { status, action, error, isDemo, connectWithKey, disconnectSession }
}
22 changes: 13 additions & 9 deletions apps/dfm/app/components/upload-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ export const UploadPanel = ({
onUpload,
onDisconnect,
isDisconnecting,
showDisconnect,
}: {
error: string | null
status: UploadStatus
onUpload: (file: File) => Promise<void>
onDisconnect: () => Promise<void>
isDisconnecting: boolean
showDisconnect: boolean
}) => {
const [file, setFile] = useState<File | null>(null)
const isUploading = status !== 'idle'
Expand Down Expand Up @@ -71,15 +73,17 @@ export const UploadPanel = ({
{uploadLabel(status)}
</Button>
</div>
<Button
type="button"
variant="muted"
isLoading={isDisconnecting}
disabled={isBusy}
onClick={() => void onDisconnect()}
>
Disconnect API key
</Button>
{showDisconnect ? (
<Button
type="button"
variant="muted"
isLoading={isDisconnecting}
disabled={isBusy}
onClick={() => void onDisconnect()}
>
Disconnect API key
</Button>
) : null}
</div>
)
}
9 changes: 7 additions & 2 deletions apps/dfm/app/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -28,14 +30,17 @@ const HomeRoute = () => {
</AppHeader>

{session.status === 'checking' ? (
<p className="mt-8 text-sm text-ink-muted">Checking local session…</p>
// A demo key is being tried; hold the page rather than flash the
// manual key form on the way to the uploader.
<p className="mt-8 text-sm text-ink-muted">Connecting…</p>
) : session.status === 'connected' ? (
<UploadPanel
error={partUpload.error ?? session.error}
status={partUpload.status}
onUpload={partUpload.upload}
onDisconnect={session.disconnectSession}
isDisconnecting={session.action === 'disconnecting'}
showDisconnect={!session.isDemo}
/>
) : (
<ConnectionPanel
Expand Down
60 changes: 59 additions & 1 deletion apps/dfm/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe('DFM Hono API', () => {
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' },
Expand All @@ -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<Response>>()
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 }),
Expand Down
26 changes: 20 additions & 6 deletions apps/dfm/server/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,18 @@ const clearConnection = (c: Context<AppEnv>): void => {
deleteCookie(c, CONNECTION_COOKIE, cookieOptions)
}

/** Encrypts the BYOK API key into an eight-hour HttpOnly connection cookie. */
export const setConnection = async (c: Context<AppEnv>, apiKey: string): Promise<void> => {
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<AppEnv>,
apiKey: string,
isDemo = false,
): Promise<void> => {
const token = await new EncryptJWT({ apiKey, isDemo })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
.setIssuedAt()
.setIssuer(ISSUER)
Expand All @@ -47,10 +56,10 @@ export const setConnection = async (c: Context<AppEnv>, 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<AppEnv>): Promise<string | null> => {
export const readConnection = async (c: Context<AppEnv>): Promise<Connection | null> => {
const token = getCookie(c, CONNECTION_COOKIE)
if (!token) {
return null
Expand All @@ -64,7 +73,9 @@ export const readApiKey = async (c: Context<AppEnv>): Promise<string | null> =>
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
Expand All @@ -74,6 +85,9 @@ export const readApiKey = async (c: Context<AppEnv>): Promise<string | null> =>
}
}

export const readApiKey = async (c: Context<AppEnv>): Promise<string | null> =>
(await readConnection(c))?.apiKey ?? null

export { clearConnection }

export const requireApiKey = async (c: Context<AppEnv>): Promise<string> => {
Expand Down
Loading