@cantera/model-upload-pagetemplate
A two-legged upload and viewing page over the app's own OSS bucket: drag-and-drop signed S3 uploads with archive support, translation options and tracking with manifest diagnostics, a sidebar model list with search, and a shareable full-bleed viewer.
npx shadcn@latest add @cantera/model-upload-pageInstalled at /upload: the page, its loading UI, the two-legged /api/models/upload route (bucket bootstrap, signed S3 uploads, translation jobs, manifest status), and /api/viewer-token. ModelUpload is the screen. No sign-in is installed: every route runs on the app's credentials, so add your own access control before shipping beyond a trusted team.
Next: 1. Fill .env.local: APS_CLIENT_ID and APS_CLIENT_SECRET from aps.autodesk.com; APS_BUCKET optional (derived from the client id); APP_ORIGIN in production. 2. Run next dev and open /upload.
Files over 250 MB are rejected; raise PART_SIZE or MAX_PARTS in the route. Reference: https://canteraui.vercel.app/components/model-upload-page
ModelUpload fetches from uploadEndpoint (default /api/models/upload) and viewerTokenEndpoint (default /api/viewer-token), the routes this template ships. The upload protocol is start, signed part URLs, finish, then status polling with manifest diagnostics; any handler that speaks it works. embedded drops the page chrome so the screen fits a docs or preview frame.
| Prop | Type | Default | Description |
|---|---|---|---|
| uploadEndpoint | string | '/api/models/upload' | Two-legged route implementing the models, start, finish, and status contract over the app bucket. |
| viewerTokenEndpoint | string | '/api/viewer-token' | Separate two-legged viewer token route, scoped to viewables:read. Upload-scoped tokens never cross into the viewer. |
| embedded | boolean | false | Constrains the desktop sidebar and shell height to the nearest positioned preview container, and skips writing ?urn= to the URL. Leave false for the full-page route. |
| Request | Type | Description |
|---|---|---|
| kind=models | GET | Ensures the app bucket exists and lists its objects as { name, urn, size } models. |
| kind=start | POST · name, size | Returns signed S3 part URLs for the object the browser uploads to directly. |
| kind=finish | POST · objectId, uploadKey, views, masterViews, zipEntrypoint | Completes the signed upload and submits the svf2 job — compressed with the archive root when zipEntrypoint is set. |
| kind=status | GET · urn | Reads the Model Derivative manifest into the translation status vocabulary, with the derivative diagnostic messages. |
This is the exact code the CLI installs into your project — you own it from there.
import { ModelUpload } from '@/components/model-upload'
// Two-legged: the page talks to the app's own OSS bucket and needs no user
// sign-in. Gate the route yourself before exposing it beyond a trusted team.
export default function ModelUploadPage() {
return <ModelUpload />
}
import { LoaderCircleIcon } from 'lucide-react'
export default function ModelUploadLoading() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-3 bg-background p-6">
<output className="flex items-center gap-2 text-muted-foreground text-sm">
<span aria-hidden className="grid size-4 animate-spin place-items-center">
<LoaderCircleIcon className="size-4" />
</span>
Checking your Autodesk connection
</output>
</main>
)
}
'use client'
import {
BoxesIcon,
BoxIcon,
Building2Icon,
EyeOffIcon,
KeyRoundIcon,
LoaderCircleIcon,
PlusIcon,
UploadIcon,
} from 'lucide-react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { APSViewer } from '@/components/ui/aps-viewer/aps-viewer'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FileDropZone } from '@/components/ui/file-drop-zone'
import type { FinderEntry } from '@/components/ui/finder'
import { HubSidebar } from '@/components/ui/hub-sidebar'
import type { HubTreeItemNode } from '@/components/ui/hub-tree'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ModelStatusCard } from '@/components/ui/model-status-card'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { type Item, type ModelTranslationStatus, normalizeSearchText } from '@/lib/project-types'
import { MODEL_FILE_ACCEPT, type UploadFile, type UploadRejection } from '@/lib/upload-types'
import { cn } from '@/lib/utils'
import { AEC_STARTER_EXTENSIONS } from '@/lib/viewer-extension-types'
import type { GetAccessToken } from '@/lib/viewer-types'
export interface ModelUploadProps {
uploadEndpoint?: string
viewerTokenEndpoint?: string
embedded?: boolean
}
interface BucketModel {
name: string
urn: string
size?: number
status?: ModelTranslationStatus
}
type ModelsState =
| { status: 'loading' }
| { status: 'ready'; models: BucketModel[] }
| { status: 'error'; message: string }
interface TranslationSnapshot {
status: ModelTranslationStatus
progress?: string
messages: string[]
}
type SelectionState =
| { kind: 'checking' }
| { kind: 'translating'; snapshot: TranslationSnapshot }
| { kind: 'ready' }
| { kind: 'failed'; snapshot: TranslationSnapshot }
interface ViewerIssue {
kind: 'unviewable' | 'no-credentials' | 'error'
detail: string
}
interface StartResponse {
objectKey?: string
objectId?: string
uploadKey?: string
urls?: string[]
partSize?: number
error?: string
}
interface FinishResponse {
urn?: string
error?: string
}
interface StatusResponse {
status?: ModelTranslationStatus
progress?: string
messages?: string[]
error?: string
}
interface ActiveUpload {
file: File
controller: AbortController
zipEntrypoint?: string
xhr?: XMLHttpRequest
cancelled?: boolean
lastProgressAt?: number
/** Set once finish succeeds — retries resume polling, never re-upload. */
urn?: string
}
const UPLOAD_ACCEPT = `${MODEL_FILE_ACCEPT},.zip`
const STATUS_POLL_MS = 2500
const STATUS_POLL_MAX_MS = 15_000
const STATUS_POLL_TIMEOUT_MS = 10 * 60 * 1000
const PROGRESS_UPDATE_MS = 100
const EMPTY_MODELS: BucketModel[] = []
const rejectionReasonLabel = {
'file-type': 'is not a supported file type',
'file-size': 'is larger than the size limit',
'file-count': 'exceeds the file limit',
} satisfies Record<UploadRejection['reason'], string>
function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('The operation was aborted.', 'AbortError'))
return
}
const onAbort = () => {
window.clearTimeout(timer)
reject(new DOMException('The operation was aborted.', 'AbortError'))
}
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function nextPollDelay(current: number): number {
return Math.min(STATUS_POLL_MAX_MS, Math.round(current * 1.5))
}
function isZip(name: string): boolean {
return name.toLowerCase().endsWith('.zip')
}
function viewerIssueFor(error: Error): ViewerIssue {
if (
error.message.includes('viewer token is unavailable') ||
error.message.includes('getAccessToken failed')
) {
return { kind: 'no-credentials', detail: error.message }
}
const unviewable =
/Document\.load failed \(5\)/.test(error.message) ||
/\b404\b/.test(error.message) ||
error.message.includes('document has no viewable geometry')
return { kind: unviewable ? 'unviewable' : 'error', detail: error.message }
}
function PaneState({
icon,
title,
description,
children,
role,
}: {
icon: ReactNode
title: string
description: ReactNode
children?: ReactNode
role?: 'status'
}) {
return (
<div role={role} className="flex max-w-sm flex-col items-center text-center">
<span className="grid size-12 place-items-center rounded-lg bg-background text-muted-foreground shadow-sm">
{icon}
</span>
<h2 className="mt-4 text-balance font-heading font-medium text-lg">{title}</h2>
<p className="mt-1.5 text-pretty text-muted-foreground text-sm">{description}</p>
{children}
</div>
)
}
function modelNode(model: BucketModel): HubTreeItemNode {
const item: Item = {
id: model.urn,
name: model.name,
type: 'item',
translationStatus: model.status,
tip: {
id: model.urn,
versionNumber: 1,
displayName: model.name,
createTime: '',
createdBy: '',
storageSize: model.size ?? 0,
derivativeUrn: model.urn,
},
}
return {
id: `model:${model.urn}`,
name: model.name,
type: 'item',
value: item,
hasChildren: false,
}
}
function ModelUpload({
uploadEndpoint = '/api/models/upload',
viewerTokenEndpoint = '/api/viewer-token',
embedded = false,
}: ModelUploadProps) {
const [modelsState, setModelsState] = useState<ModelsState>({ status: 'loading' })
const [selected, setSelected] = useState<{ urn: string; name: string }>()
const [selectionState, setSelectionState] = useState<SelectionState>({ kind: 'checking' })
const [viewerIssue, setViewerIssue] = useState<ViewerIssue>()
const [query, setQuery] = useState('')
const [uploadOpen, setUploadOpen] = useState(false)
const [files, setFiles] = useState<UploadFile[]>([])
const [pendingZips, setPendingZips] = useState<{ id: string; name: string; entry: string }[]>([])
const [rejection, setRejection] = useState<string>()
const [sheets, setSheets] = useState(true)
const [models3d, setModels3d] = useState(true)
const [masterViews, setMasterViews] = useState(false)
const uploads = useRef(new Map<string, ActiveUpload>())
const completedUrns = useRef(new Set<string>())
useEffect(
() => () => {
for (const upload of uploads.current.values()) {
upload.cancelled = true
upload.controller.abort()
upload.xhr?.abort()
}
uploads.current.clear()
},
[],
)
// Callers set the loading state first; the initial load relies on the
// state initializer so the effect never writes state synchronously.
const loadModels = useCallback(
(signal?: AbortSignal) =>
fetch(`${uploadEndpoint}?kind=models`, { cache: 'no-store', signal })
.then(async (response) => {
const body = (await response.json()) as { models?: BucketModel[]; error?: string }
if (!response.ok || !body.models) {
throw new Error(body.error ?? 'Models could not be listed.')
}
setModelsState({ status: 'ready', models: body.models })
return body.models
})
.catch((error: unknown) => {
if (signal?.aborted) return [] as BucketModel[]
setModelsState({
status: 'error',
message: error instanceof Error ? error.message : 'Models could not be listed.',
})
return [] as BucketModel[]
}),
[uploadEndpoint],
)
useEffect(() => {
const controller = new AbortController()
void loadModels(controller.signal).then((models) => {
// A shared link restores its model: ?urn=... names the selection.
const shared = new URLSearchParams(window.location.search).get('urn')
if (!shared) return
const model = models.find((entry) => entry.urn === shared)
if (model) {
if (model.status === 'success') completedUrns.current.add(model.urn)
setSelected(model)
setSelectionState({ kind: model.status === 'success' ? 'ready' : 'checking' })
setViewerIssue(undefined)
}
})
return () => controller.abort()
}, [loadModels])
function selectModel(
model: { urn: string; name: string },
options?: { fromUrl?: boolean; ready?: boolean },
): void {
if (options?.ready) completedUrns.current.add(model.urn)
setSelected(model)
setSelectionState({ kind: options?.ready ? 'ready' : 'checking' })
setViewerIssue(undefined)
if (!embedded && !options?.fromUrl) {
const url = new URL(window.location.href)
url.searchParams.set('urn', model.urn)
window.history.replaceState(null, '', url)
}
}
// The selected model renders only once its manifest settles: poll while the
// translation is still running.
useEffect(() => {
if (!selected) return
if (completedUrns.current.has(selected.urn)) return
const controller = new AbortController()
async function track(urn: string): Promise<void> {
const deadline = Date.now() + STATUS_POLL_TIMEOUT_MS
let pollDelay = STATUS_POLL_MS
while (Date.now() < deadline && !controller.signal.aborted) {
try {
const response = await fetch(
`${uploadEndpoint}?kind=status&urn=${encodeURIComponent(urn)}`,
{
cache: 'no-store',
signal: controller.signal,
},
)
const body = (await response.json()) as StatusResponse
if (controller.signal.aborted) return
if (!response.ok) throw new Error(body.error ?? 'The translation status is unavailable.')
const snapshot: TranslationSnapshot = {
status: body.status ?? 'pending',
progress: body.progress,
messages: body.messages ?? [],
}
if (snapshot.status === 'success') {
setSelectionState({ kind: 'ready' })
return
}
if (snapshot.status === 'failed' || snapshot.status === 'timeout') {
setSelectionState({ kind: 'failed', snapshot })
return
}
setSelectionState({ kind: 'translating', snapshot })
} catch (error) {
if (controller.signal.aborted) return
setSelectionState({
kind: 'failed',
snapshot: {
status: 'failed',
messages: [
error instanceof Error ? error.message : 'The translation status is unavailable.',
],
},
})
return
}
await delay(pollDelay, controller.signal).catch(() => undefined)
pollDelay = nextPollDelay(pollDelay)
}
}
void track(selected.urn)
return () => controller.abort()
}, [selected, uploadEndpoint])
const getAccessToken = useCallback<GetAccessToken>(async () => {
const response = await fetch(viewerTokenEndpoint, { cache: 'no-store' })
if (!response.ok) throw new Error('The viewer token is unavailable.')
return (await response.json()) as Awaited<ReturnType<GetAccessToken>>
}, [viewerTokenEndpoint])
function patchFile(id: string, patch: Partial<UploadFile>): void {
setFiles((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
)
}
async function postUpload<T>(body: unknown, signal: AbortSignal): Promise<T> {
const response = await fetch(uploadEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
})
const parsed = (await response.json()) as T & { error?: string }
if (!response.ok) throw new Error(parsed.error ?? 'The upload request failed.')
return parsed
}
function putPart(
id: string,
url: string,
part: Blob,
onProgress: (loaded: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const active = uploads.current.get(id)
if (!active || active.cancelled) {
reject(new Error('cancelled'))
return
}
const xhr = new XMLHttpRequest()
active.xhr = xhr
xhr.open('PUT', url)
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return
const now = performance.now()
if (event.loaded < event.total && now - (active.lastProgressAt ?? 0) < PROGRESS_UPDATE_MS) {
return
}
active.lastProgressAt = now
onProgress(event.loaded)
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else if (xhr.status === 413) {
reject(new Error('The file is larger than this storage accepts.'))
} else reject(new Error(`Upload failed (${xhr.status}).`))
}
xhr.onerror = () => reject(new Error('Upload failed. Check your connection.'))
xhr.onabort = () => reject(new Error('cancelled'))
xhr.send(part)
})
}
async function trackUploadTranslation(id: string, urn: string): Promise<boolean> {
let elapsed = 0
let pollDelay = STATUS_POLL_MS
while (elapsed < STATUS_POLL_TIMEOUT_MS) {
const active = uploads.current.get(id)
if (!active || active.cancelled) return false
const response = await fetch(`${uploadEndpoint}?kind=status&urn=${encodeURIComponent(urn)}`, {
cache: 'no-store',
signal: active.controller.signal,
})
const body = (await response.json()) as StatusResponse
if (!response.ok) throw new Error(body.error ?? 'The translation status is unavailable.')
if (body.status === 'success') {
patchFile(id, { phase: 'complete', processingLabel: undefined })
completedUrns.current.add(urn)
return true
}
if (body.status === 'failed' || body.status === 'timeout') {
patchFile(id, {
phase: 'error',
error: body.messages?.[0] ?? 'The upload finished but translation failed.',
retryable: false,
})
return false
}
patchFile(id, {
processingLabel: body.progress ? `Translating · ${body.progress}` : 'Translating',
})
await delay(pollDelay, active.controller.signal)
elapsed += pollDelay
pollDelay = nextPollDelay(pollDelay)
}
throw new Error('Translation is taking longer than expected.')
}
async function runUpload(id: string): Promise<void> {
const active = uploads.current.get(id)
if (!active) return
const { file } = active
try {
if (active.urn) {
patchFile(id, { phase: 'processing', processingLabel: 'Translating', error: undefined })
if (!(await trackUploadTranslation(id, active.urn))) return
} else {
patchFile(id, { phase: 'queued', progress: undefined, error: undefined })
const start = await postUpload<StartResponse>(
{
kind: 'start',
name: file.name,
size: file.size,
},
active.controller.signal,
)
if (
!start.objectKey ||
!start.objectId ||
!start.uploadKey ||
!start.urls?.length ||
!start.partSize
) {
throw new Error('The upload could not be started.')
}
patchFile(id, { phase: 'uploading', progress: 0 })
for (let index = 0; index < start.urls.length; index += 1) {
const url = start.urls[index]
if (!url) throw new Error('The upload could not be started.')
const from = index * start.partSize
const part = file.slice(from, Math.min(file.size, from + start.partSize))
await putPart(id, url, part, (loaded) => {
patchFile(id, { progress: file.size > 0 ? (from + loaded) / file.size : 1 })
})
}
patchFile(id, { phase: 'processing', progress: undefined, processingLabel: 'Translating' })
const finish = await postUpload<FinishResponse>(
{
kind: 'finish',
name: file.name,
objectKey: start.objectKey,
objectId: start.objectId,
uploadKey: start.uploadKey,
views: [...(sheets ? ['2d' as const] : []), ...(models3d ? ['3d' as const] : [])],
masterViews,
zipEntrypoint: active.zipEntrypoint,
},
active.controller.signal,
)
if (!finish.urn) throw new Error('The version could not be created.')
active.urn = finish.urn
if (!(await trackUploadTranslation(id, finish.urn))) return
}
const current = uploads.current.get(id)
if (!current || current.cancelled) return
const models = await loadModels(active.controller.signal)
const uploaded = models.find((entry) => entry.urn === active.urn)
if (uploaded) selectModel(uploaded, { ready: true })
uploads.current.delete(id)
} catch (error) {
const current = uploads.current.get(id)
if (!current || current.cancelled || active.controller.signal.aborted) return
const message = error instanceof Error ? error.message : 'The upload failed.'
if (message === 'cancelled') return
patchFile(id, { phase: 'error', progress: undefined, error: message, retryable: true })
} finally {
active.xhr = undefined
}
}
function startFile(file: File, zipEntrypoint?: string): void {
const id = crypto.randomUUID()
uploads.current.set(id, { file, zipEntrypoint, controller: new AbortController() })
setFiles((current) => [...current, { id, name: file.name, size: file.size, phase: 'queued' }])
void runUpload(id)
}
function handleDropFiles(dropped: File[]): void {
setRejection(undefined)
for (const file of dropped) {
if (isZip(file.name)) {
// An archive translates its root design file — ask which one first.
const id = crypto.randomUUID()
uploads.current.set(id, { file, controller: new AbortController() })
setPendingZips((current) => [...current, { id, name: file.name, entry: '' }])
continue
}
startFile(file)
}
}
function startZip(id: string): void {
const pending = pendingZips.find((entry) => entry.id === id)
const active = uploads.current.get(id)
if (!pending || !active || !pending.entry.trim()) return
active.zipEntrypoint = pending.entry.trim()
setPendingZips((current) => current.filter((entry) => entry.id !== id))
setFiles((current) => [
...current,
{ id, name: active.file.name, size: active.file.size, phase: 'queued' },
])
void runUpload(id)
}
function handleReject(rejections: UploadRejection[]): void {
const first = rejections[0]
if (!first) return
const rest = rejections.length - 1
setRejection(
`${first.file.name} ${rejectionReasonLabel[first.reason]}${
rest > 0 ? ` (and ${rest} more ${rest === 1 ? 'file was' : 'files were'} skipped)` : ''
}.`,
)
}
function handleRemove(file: UploadFile): void {
const active = uploads.current.get(file.id)
if (active) {
active.cancelled = true
active.controller.abort()
active.xhr?.abort()
uploads.current.delete(file.id)
}
setFiles((current) => current.filter((entry) => entry.id !== file.id))
}
function removePendingZip(id: string): void {
const active = uploads.current.get(id)
if (active) {
active.cancelled = true
active.controller.abort()
uploads.current.delete(id)
}
setPendingZips((current) => current.filter((entry) => entry.id !== id))
}
async function handleRetry(file: UploadFile): Promise<void> {
const active = uploads.current.get(file.id)
if (!active) return
active.cancelled = false
active.controller = new AbortController()
active.xhr = undefined
await runUpload(file.id)
}
const models = modelsState.status === 'ready' ? modelsState.models : EMPTY_MODELS
const nodes = useMemo(() => models.map(modelNode), [models])
const finderEntries: FinderEntry[] = useMemo(() => {
const term = normalizeSearchText(query.trim())
return term
? models
.filter((model) => normalizeSearchText(model.name).includes(term))
.map((model) => ({ item: modelNode(model).value }))
: []
}, [models, query])
const treeEmpty =
modelsState.status === 'loading' ? (
<output className="flex min-h-11 items-center gap-2 px-2 py-4 text-muted-foreground text-xs">
<LoaderCircleIcon aria-hidden className="size-3.5 animate-spin" />
Loading models
</output>
) : modelsState.status === 'error' ? (
<div role="alert" className="flex flex-col items-start gap-3 px-2 py-4">
<p className="text-sm">{modelsState.message}</p>
<Button
variant="outline"
size="sm"
className="relative after:absolute after:-inset-y-2 after:inset-x-0"
onClick={() => {
setModelsState({ status: 'loading' })
void loadModels()
}}
>
Retry
</Button>
</div>
) : (
<div className="flex flex-col items-start px-2 py-4">
<p className="font-medium text-sm">No models yet</p>
<p className="mt-1 text-muted-foreground text-xs">
Upload a Revit, IFC, DWG, or Navisworks file to view it here.
</p>
<Button
variant="outline"
className="mt-4 min-h-11 w-full"
onClick={() => setUploadOpen(true)}
>
<UploadIcon aria-hidden />
Upload a model
</Button>
</div>
)
const failedTranslation =
selected && selectionState.kind === 'failed'
? {
urn: selected.urn,
name: selected.name,
status: selectionState.snapshot.status,
progress: selectionState.snapshot.progress,
error: selectionState.snapshot.messages[0] ?? 'Translation failed.',
}
: undefined
return (
<SidebarProvider
className={
embedded
? 'relative h-[36rem] min-h-[36rem] overflow-hidden bg-background'
: 'h-svh min-h-[32rem] overflow-hidden bg-background'
}
>
<HubSidebar
user={{
name: 'Model library',
detail: 'App storage',
avatar: (
<span className="grid size-8 place-items-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<BoxesIcon aria-hidden className="size-4" />
</span>
),
}}
finder={{
query,
onQueryChange: setQuery,
groups: [{ id: 'models', label: 'Models', entries: finderEntries }],
onItemOpen: (entry) =>
selectModel(
{ urn: entry.item.id, name: entry.item.name },
{ ready: entry.item.translationStatus === 'success' },
),
placeholder: 'Find a model',
emptyLabel: `No models match "${query.trim()}".`,
}}
tree={{
nodes,
expandedIds: [],
selectedId: selected ? `model:${selected.urn}` : undefined,
empty: treeEmpty,
'aria-label': 'Uploaded models',
onExpand: () => {},
onCollapse: () => {},
onItemOpen: (item) =>
selectModel(
{ urn: item.id, name: item.name },
{ ready: item.translationStatus === 'success' },
),
}}
treeLabel={models.length > 0 ? `Models · ${models.length}` : 'Models'}
treeAction={
<button
type="button"
aria-label="Upload models"
// The primitive drops its extended hit area on md+: keep the 44px
// target on every pointer.
className="after:-inset-3.5 md:after:block"
onClick={() => setUploadOpen(true)}
>
<PlusIcon aria-hidden />
</button>
}
collapsible="icon"
className={
embedded ? 'border-border border-r md:absolute md:h-full' : 'border-border border-r'
}
/>
<SidebarInset
className={
embedded
? 'h-full min-h-0 min-w-0 overflow-hidden'
: 'h-svh min-h-0 min-w-0 overflow-hidden'
}
>
<header className="flex min-h-16 shrink-0 items-center gap-3 border-b bg-background px-2 sm:px-4">
<SidebarTrigger className="size-11 shrink-0" />
<span className="hidden size-9 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground sm:grid">
<Building2Icon aria-hidden className="size-4" />
</span>
<div className="min-w-0 flex-1">
<h1 className="truncate font-heading font-medium text-sm sm:text-base">Model viewer</h1>
<p className="truncate text-muted-foreground text-xs">
{selected?.name ?? 'Models in this app’s storage'}
</p>
</div>
<Button className="min-h-11 shrink-0 gap-1.5" onClick={() => setUploadOpen(true)}>
<UploadIcon aria-hidden />
<span className="hidden sm:inline">Upload models</span>
</Button>
</header>
<section className="relative min-h-0 min-w-0 flex-1 bg-muted" aria-label="Model viewer">
{selected && selectionState.kind === 'ready' && !viewerIssue ? (
<APSViewer
urn={selected.urn}
getAccessToken={getAccessToken}
extensions={AEC_STARTER_EXTENSIONS}
profile="aec"
toolbar="native"
radius={0}
className="size-full"
onViewerReady={(viewer) => viewer.prefs.set('openPropertiesOnSelect', true)}
onError={(error) => setViewerIssue(viewerIssueFor(error))}
/>
) : (
<div className="absolute inset-0 grid place-items-center overflow-y-auto p-6">
{!selected ? (
<PaneState
icon={<BoxIcon aria-hidden className="size-6" />}
title="Choose a model"
description={
models.length > 0
? 'Pick a model from the sidebar, or upload a new one.'
: 'Upload a design file to translate and view it here.'
}
/>
) : viewerIssue?.kind === 'no-credentials' ? (
<PaneState
role="status"
icon={<KeyRoundIcon aria-hidden className="size-6" />}
title="Viewer unavailable"
description="The viewer token endpoint needs real APS credentials. The upload and translation flow still works without them."
/>
) : viewerIssue?.kind === 'unviewable' ? (
<PaneState
role="status"
icon={<EyeOffIcon aria-hidden className="size-6" />}
title="No preview for this file"
description={
<>Autodesk has not produced a viewable version of “{selected.name}”.</>
}
>
<details className="mt-4 w-full text-left">
<summary className="w-fit text-muted-foreground text-xs">
Technical details
</summary>
<p className="mt-1 break-all font-mono text-muted-foreground text-xs">
{viewerIssue.detail}
</p>
</details>
</PaneState>
) : viewerIssue ? (
<ModelStatusCard
translation={{
urn: selected.urn,
name: selected.name,
status: 'failed',
error: viewerIssue.detail,
}}
className="max-w-lg"
/>
) : failedTranslation ? (
<div className="flex w-full max-w-lg flex-col gap-3">
<ModelStatusCard translation={failedTranslation} />
{selectionState.kind === 'failed' &&
selectionState.snapshot.messages.length > 1 && (
<ul className="flex flex-col gap-1 rounded-lg border border-border bg-card p-3">
{selectionState.snapshot.messages.slice(1).map((message) => (
<li key={message} className="text-muted-foreground text-xs">
{message}
</li>
))}
</ul>
)}
</div>
) : (
<ModelStatusCard
translation={{
urn: selected.urn,
name: selected.name,
status:
selectionState.kind === 'translating'
? selectionState.snapshot.status
: 'pending',
progress:
selectionState.kind === 'translating'
? selectionState.snapshot.progress
: undefined,
}}
className="max-w-lg"
/>
)}
</div>
)}
</section>
</SidebarInset>
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogContent className="w-[min(40rem,calc(100%-2rem))] gap-4 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Upload models</DialogTitle>
<DialogDescription>
Files land in this app’s storage and translate for the viewer.
</DialogDescription>
</DialogHeader>
<FileDropZone
files={files}
accept={UPLOAD_ACCEPT}
onDropFiles={handleDropFiles}
onReject={handleReject}
onRetry={handleRetry}
onRemove={handleRemove}
/>
<fieldset className="flex flex-col">
<legend className="mb-1 font-medium text-muted-foreground text-xs">
Translation outputs
</legend>
<div className="flex min-h-11 items-center gap-3">
<Checkbox
id="model-upload-sheets"
checked={sheets}
// The job needs at least one view: the last one on stays on.
aria-disabled={(sheets && !models3d) || undefined}
onCheckedChange={(checked) => {
if (sheets && !models3d) return
setSheets(checked === true)
}}
/>
<Label htmlFor="model-upload-sheets" className="flex-1 flex-col items-start gap-0.5">
2D sheets
<span className="font-normal text-muted-foreground text-xs">
Plans and sheet views
</span>
</Label>
</div>
<div className="flex min-h-11 items-center gap-3">
<Checkbox
id="model-upload-models"
checked={models3d}
aria-disabled={(models3d && !sheets) || undefined}
onCheckedChange={(checked) => {
if (models3d && !sheets) return
setModels3d(checked === true)
}}
/>
<Label htmlFor="model-upload-models" className="flex-1 flex-col items-start gap-0.5">
3D views
<span className="font-normal text-muted-foreground text-xs">
The model geometry the viewer opens
</span>
</Label>
</div>
<div className="flex min-h-11 items-center gap-3">
<Checkbox
id="model-upload-master-views"
checked={masterViews}
onCheckedChange={(checked) => setMasterViews(checked === true)}
/>
<Label
htmlFor="model-upload-master-views"
className="flex-1 flex-col items-start gap-0.5"
>
Revit master views
<span className="font-normal text-muted-foreground text-xs">
Also export phase-based master views from Revit files
</span>
</Label>
</div>
</fieldset>
{pendingZips.map((zip) => (
<div key={zip.id} className="flex flex-wrap items-end gap-2">
<div className="flex min-w-48 flex-1 flex-col gap-1.5">
<Label htmlFor={`zip-entry-${zip.id}`} className="text-xs">
Main design inside {zip.name}
</Label>
<Input
id={`zip-entry-${zip.id}`}
value={zip.entry}
placeholder="model.rvt"
className="min-h-11"
onChange={(event) =>
setPendingZips((current) =>
current.map((entry) =>
entry.id === zip.id ? { ...entry, entry: event.target.value } : entry,
),
)
}
/>
</div>
<Button
className="min-h-11"
aria-disabled={!zip.entry.trim() || undefined}
onClick={() => startZip(zip.id)}
>
Upload archive
</Button>
<Button variant="ghost" className="min-h-11" onClick={() => removePendingZip(zip.id)}>
Remove
</Button>
</div>
))}
{rejection && (
<p role="status" className={cn('text-status-warning text-xs')}>
{rejection}
</p>
)}
</DialogContent>
</Dialog>
</SidebarProvider>
)
}
export { ModelUpload }
import { APS_BASE_URL, type TokenSource } from 'aec-auth'
import { apsOAuth, memoryVaultStore, vaultTokenSource } from 'aec-auth/vault'
import type { ModelTranslationStatus } from '@/lib/project-types'
import {
displayObjectName,
isOwnedUrn,
issuedObjectKey,
MAX_PARTS,
objectIdFor,
PART_SIZE,
parseUploadRequest,
type UploadFinishRequest,
type UploadStartRequest,
} from './upload-request'
interface OssObject {
objectKey?: string
objectId?: string
size?: number
}
interface SignedUploadResponse {
uploadKey?: string
urls?: string[]
}
interface ManifestMessage {
type?: string
code?: string
message?: string | string[]
}
interface ManifestDerivative {
messages?: ManifestMessage[]
children?: { messages?: ManifestMessage[] }[]
}
interface ManifestResponse {
status?: string
progress?: string
derivatives?: ManifestDerivative[]
}
interface TranslateFormat {
type: 'svf2'
views: ('2d' | '3d')[]
advanced?: { generateMasterViews: boolean }
}
const UPLOAD_SCOPES = ['bucket:create', 'bucket:read', 'data:read', 'data:create', 'data:write']
const MANIFEST_CONCURRENCY = 6
// Vercel sets VERCEL_PROJECT_PRODUCTION_URL on every deployment, so a one-click
// deploy has a trusted origin before its owner knows the URL.
function deploymentOrigin(): string | undefined {
if (process.env.APP_ORIGIN) return process.env.APP_ORIGIN
const vercel = process.env.VERCEL_PROJECT_PRODUCTION_URL
return vercel ? `https://${vercel}` : undefined
}
function apiBase(origin: string): string {
const configured = process.env.APS_AUTH_BASE_URL
if (!configured) return APS_BASE_URL
if (!configured.startsWith('/')) return configured
const trusted = deploymentOrigin()
if (!trusted && process.env.NODE_ENV === 'production') {
throw new Error('APP_ORIGIN is required with a relative APS_AUTH_BASE_URL in production')
}
return `${trusted ? new URL(trusted).origin : origin}${configured}`
}
function segment(value: string): string {
return encodeURIComponent(value).replaceAll('%3A', ':')
}
/** The app's private bucket — like every OSS bucket key, globally unique per
* client, so the default derives from the client id. */
function bucketKey(): string {
const configured = process.env.APS_BUCKET
if (configured) return configured
const clientId = process.env.APS_CLIENT_ID ?? ''
const safe = clientId.toLowerCase().replace(/[^-_.a-z0-9]/g, '')
return `${safe.slice(0, 100)}-cantera-models`
}
const tokenSources = new Map<string, TokenSource>()
function uploadTokenSource(base: string): TokenSource {
let source = tokenSources.get(base)
if (source) return source
const clientId = process.env.APS_CLIENT_ID
const clientSecret = process.env.APS_CLIENT_SECRET
if (!clientId || !clientSecret) throw new Error('APS credentials are not configured')
source = vaultTokenSource({
store: memoryVaultStore(),
providers: {
aps: apsOAuth({ clientId, clientSecret, baseUrl: base === APS_BASE_URL ? undefined : base }),
},
})
tokenSources.set(base, source)
return source
}
async function accessToken(base: string): Promise<string> {
const token = await uploadTokenSource(base).getToken({
provider: 'aps',
subject: { type: 'app' },
scopes: UPLOAD_SCOPES,
})
return token.token
}
async function apsRequest(
base: string,
path: string,
init?: { method?: string; body?: unknown; headers?: Record<string, string> },
): Promise<Response> {
let response: Response | undefined
for (let attempt = 0; attempt < 2; attempt += 1) {
const headers = new Headers({
Authorization: `Bearer ${await accessToken(base)}`,
Accept: 'application/json',
})
if (init?.body !== undefined) headers.set('Content-Type', 'application/json')
for (const [name, value] of Object.entries(init?.headers ?? {})) headers.set(name, value)
response = await fetch(`${base}${path}`, {
method: init?.method ?? 'GET',
headers,
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
cache: 'no-store',
})
// A recycled provider (the emulator resets between dev restarts)
// invalidates cached app tokens: mint a fresh one and retry once.
if (response.status !== 401 || attempt > 0) return response
tokenSources.delete(base)
}
return response as Response
}
async function apsFetch<T>(
base: string,
path: string,
init?: { method?: string; body?: unknown; headers?: Record<string, string> },
): Promise<T> {
const response = await apsRequest(base, path, init)
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
return (await response.json()) as T
}
async function ensureBucket(base: string): Promise<string> {
const bucket = bucketKey()
const response = await apsRequest(base, `/oss/v2/buckets/${segment(bucket)}/details`)
if (response.ok) return bucket
if (response.status !== 404) {
throw new Error(`${response.status} ${response.statusText}`.trim())
}
// Two first requests can race here; the loser's 409 still means the bucket exists.
const created = await apsRequest(base, '/oss/v2/buckets', {
method: 'POST',
body: { bucketKey: bucket, policyKey: 'persistent' },
})
if (!created.ok && created.status !== 409) {
throw new Error(`${created.status} ${created.statusText}`.trim())
}
return bucket
}
function base64Url(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url')
}
async function manifestStatus(base: string, urn: string): Promise<ModelTranslationStatus> {
const response = await apsRequest(base, `/modelderivative/v2/designdata/${segment(urn)}/manifest`)
if (!response.ok) return 'pending'
const manifest = (await response.json()) as ManifestResponse
const known: ModelTranslationStatus[] = ['pending', 'inprogress', 'success', 'failed', 'timeout']
return known.find((value) => value === manifest.status) ?? 'pending'
}
async function mapWithConcurrency<T, Result>(
values: T[],
concurrency: number,
transform: (value: T) => Promise<Result>,
): Promise<Result[]> {
const results: Result[] = Array.from({ length: values.length })
let nextValue = 0
async function worker(): Promise<void> {
for (;;) {
const index = nextValue
nextValue += 1
const value = values[index]
if (value === undefined) return
results[index] = await transform(value)
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => worker()))
return results
}
async function listModels(base: string): Promise<Response> {
const bucket = await ensureBucket(base)
const listing = await apsFetch<{ items?: OssObject[] }>(
base,
`/oss/v2/buckets/${segment(bucket)}/objects?limit=100`,
)
const objects = (listing.items ?? []).filter(
(object): object is OssObject & { objectKey: string; objectId: string } =>
Boolean(object.objectKey && object.objectId),
)
const models = await mapWithConcurrency(objects, MANIFEST_CONCURRENCY, async (object) => {
const urn = base64Url(object.objectId)
return {
name: displayObjectName(object.objectKey),
urn,
size: object.size,
status: await manifestStatus(base, urn),
}
})
return Response.json({ models }, { headers: { 'Cache-Control': 'no-store' } })
}
async function startUpload(base: string, request: UploadStartRequest): Promise<Response> {
const parts = Math.max(1, Math.ceil(request.size / PART_SIZE))
if (parts > MAX_PARTS) {
return Response.json(
{ error: `Files over ${(PART_SIZE * MAX_PARTS) / (1024 * 1024)} MB are not supported.` },
{ status: 413, headers: { 'Cache-Control': 'no-store' } },
)
}
const bucket = await ensureBucket(base)
const objectKey = issuedObjectKey(request.name)
const signed = await apsFetch<SignedUploadResponse>(
base,
`/oss/v2/buckets/${segment(bucket)}/objects/${segment(objectKey)}/signeds3upload?parts=${parts}&firstPart=1`,
)
if (!signed.uploadKey || !signed.urls?.length) {
throw new Error('The signed upload could not be created.')
}
return Response.json(
{
objectKey,
objectId: objectIdFor(bucket, objectKey),
uploadKey: signed.uploadKey,
urls: signed.urls,
partSize: PART_SIZE,
},
{ headers: { 'Cache-Control': 'no-store' } },
)
}
async function finishUpload(base: string, request: UploadFinishRequest): Promise<Response> {
const bucket = bucketKey()
await apsFetch(
base,
`/oss/v2/buckets/${segment(bucket)}/objects/${segment(request.objectKey)}/signeds3upload`,
{ method: 'POST', body: { uploadKey: request.uploadKey } },
)
const urn = base64Url(request.objectId)
const format: TranslateFormat = { type: 'svf2', views: request.views }
if (request.masterViews) format.advanced = { generateMasterViews: true }
await apsFetch(base, '/modelderivative/v2/designdata/job', {
method: 'POST',
headers: { 'x-ads-force': 'true' },
body: {
input: {
urn,
compressedUrn: Boolean(request.zipEntrypoint),
rootFilename: request.zipEntrypoint,
},
output: { formats: [format] },
},
})
return Response.json({ name: request.name, urn }, { headers: { 'Cache-Control': 'no-store' } })
}
function collectMessages(manifest: ManifestResponse): string[] {
const lines: string[] = []
for (const derivative of manifest.derivatives ?? []) {
const groups = [
derivative.messages ?? [],
...(derivative.children ?? []).map((c) => c.messages ?? []),
]
for (const message of groups.flat()) {
const text = Array.isArray(message.message) ? message.message.join(' ') : message.message
if (!text) continue
const line = message.code ? `${message.code}: ${text}` : text
if (!lines.includes(line)) lines.push(line)
}
}
return lines
}
async function translationStatus(base: string, urn: string): Promise<Response> {
const response = await apsRequest(base, `/modelderivative/v2/designdata/${segment(urn)}/manifest`)
// The manifest appears only once the job is picked up: not-yet is pending.
if (response.status === 404) {
return Response.json(
{ status: 'pending' satisfies ModelTranslationStatus },
{ headers: { 'Cache-Control': 'no-store' } },
)
}
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
const manifest = (await response.json()) as ManifestResponse
const known: ModelTranslationStatus[] = ['pending', 'inprogress', 'success', 'failed', 'timeout']
const status = known.find((value) => value === manifest.status) ?? 'pending'
return Response.json(
{ status, progress: manifest.progress, messages: collectMessages(manifest) },
{ headers: { 'Cache-Control': 'no-store' } },
)
}
function errorResponse(error: unknown): Response {
if (error instanceof Error && error.message === 'APS credentials are not configured') {
return Response.json(
{ error: 'APS credentials are not configured.' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}
console.error('Model upload request failed', error)
return Response.json(
{ error: 'The Autodesk request failed. Try again.' },
{ status: 502, headers: { 'Cache-Control': 'no-store' } },
)
}
/**
* Two-legged upload endpoint over the app's own OSS bucket — no user sign-in.
* Anyone who can reach these routes can read and write the bucket, so protect
* the deployment (or this route) with your own access control before shipping.
*
* GET /api/models/upload?kind=models
* GET /api/models/upload?kind=status&urn=...
* POST /api/models/upload { kind: 'start', name, size }
* POST /api/models/upload { kind: 'finish', name, objectId, uploadKey, views,
* masterViews, zipEntrypoint }
*/
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url)
const kind = url.searchParams.get('kind')
try {
const base = apiBase(url.origin)
if (kind === 'models') return await listModels(base)
if (kind === 'status') {
const urn = url.searchParams.get('urn')
if (!urn) return Response.json({ error: 'urn is required.' }, { status: 400 })
if (!isOwnedUrn(urn, bucketKey())) {
return Response.json(
{ error: 'urn does not belong to this upload bucket.' },
{ status: 400 },
)
}
return await translationStatus(base, urn)
}
return Response.json({ error: 'Unknown upload request.' }, { status: 400 })
} catch (error) {
return errorResponse(error)
}
}
export async function POST(request: Request): Promise<Response> {
const url = new URL(request.url)
let value: unknown
try {
value = await request.json()
} catch {
return Response.json({ error: 'The request body must be JSON.' }, { status: 400 })
}
const parsed = parseUploadRequest(value, bucketKey())
if (!parsed.ok) {
return Response.json({ error: parsed.error }, { status: parsed.status ?? 400 })
}
try {
const base = apiBase(url.origin)
if (parsed.value.kind === 'start') return await startUpload(base, parsed.value)
return await finishUpload(base, parsed.value)
} catch (error) {
return errorResponse(error)
}
}
export const PART_SIZE = 10 * 1024 * 1024
export const MAX_PARTS = 25
export const MAX_UPLOAD_BYTES = PART_SIZE * MAX_PARTS
const ISSUED_OBJECT_KEY =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}--(.+)$/i
function hasControlCharacters(value: string): boolean {
for (const character of value) {
const code = character.codePointAt(0)
if (code !== undefined && (code <= 31 || code === 127)) return true
}
return false
}
export interface UploadStartRequest {
kind: 'start'
name: string
size: number
}
export interface UploadFinishRequest {
kind: 'finish'
name: string
objectKey: string
objectId: string
uploadKey: string
views: ('2d' | '3d')[]
masterViews?: boolean
zipEntrypoint?: string
}
export type UploadRequest = UploadStartRequest | UploadFinishRequest
export type UploadRequestResult =
| { ok: true; value: UploadRequest }
| { ok: false; error: string; status?: number }
interface UntrustedUploadRequest {
kind?: unknown
name?: unknown
size?: unknown
objectKey?: unknown
objectId?: unknown
uploadKey?: unknown
views?: unknown
masterViews?: unknown
zipEntrypoint?: unknown
}
function objectRecord(value: unknown): UntrustedUploadRequest | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as UntrustedUploadRequest)
: undefined
}
function validFileName(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= 255 &&
value !== '.' &&
value !== '..' &&
!value.includes('/') &&
!value.includes('\\') &&
!hasControlCharacters(value)
)
}
function validZipEntrypoint(value: unknown): value is string | undefined {
if (value === undefined) return true
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 1024 ||
value.startsWith('/') ||
value.includes('\\') ||
hasControlCharacters(value)
) {
return false
}
return value.split('/').every((segment) => segment.length > 0 && segment !== '..')
}
export function issuedObjectKey(name: string, id = crypto.randomUUID()): string {
return `${id}--${name}`
}
export function displayObjectName(objectKey: string): string {
return ISSUED_OBJECT_KEY.exec(objectKey)?.[1] ?? objectKey
}
export function objectIdFor(bucket: string, objectKey: string): string {
return `urn:adsk.objects:os.object:${bucket}/${objectKey}`
}
export function isOwnedUrn(urn: string, bucket: string): boolean {
if (urn.length === 0 || urn.length > 2048) return false
try {
const objectId = Buffer.from(urn, 'base64url').toString('utf8')
return objectId.startsWith(`urn:adsk.objects:os.object:${bucket}/`)
} catch {
return false
}
}
export function parseUploadRequest(value: unknown, bucket: string): UploadRequestResult {
const body = objectRecord(value)
if (!body) return { ok: false, error: 'The request body must be a JSON object.' }
if (body.kind === 'start') {
if (!validFileName(body.name)) return { ok: false, error: 'name must be a valid file name.' }
if (!Number.isSafeInteger(body.size) || (body.size as number) <= 0) {
return { ok: false, error: 'size must be a positive integer.' }
}
if ((body.size as number) > MAX_UPLOAD_BYTES) {
return {
ok: false,
error: `Files over ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB are not supported.`,
status: 413,
}
}
return { ok: true, value: { kind: 'start', name: body.name, size: body.size as number } }
}
if (body.kind === 'finish') {
if (!validFileName(body.name)) return { ok: false, error: 'name must be a valid file name.' }
if (typeof body.objectKey !== 'string' || displayObjectName(body.objectKey) !== body.name) {
return { ok: false, error: 'objectKey was not issued for this file.' }
}
if (
typeof body.objectId !== 'string' ||
body.objectId !== objectIdFor(bucket, body.objectKey)
) {
return { ok: false, error: 'objectId does not belong to this upload bucket.' }
}
if (
typeof body.uploadKey !== 'string' ||
body.uploadKey.length === 0 ||
body.uploadKey.length > 2048
) {
return { ok: false, error: 'uploadKey is required.' }
}
if (!Array.isArray(body.views) || body.views.length === 0) {
return { ok: false, error: 'views must contain 2d, 3d, or both.' }
}
const views = [...new Set(body.views)]
if (views.some((view) => view !== '2d' && view !== '3d')) {
return { ok: false, error: 'views must contain only 2d or 3d.' }
}
if (body.masterViews !== undefined && typeof body.masterViews !== 'boolean') {
return { ok: false, error: 'masterViews must be a boolean.' }
}
if (!validZipEntrypoint(body.zipEntrypoint)) {
return { ok: false, error: 'zipEntrypoint must be a relative path inside the archive.' }
}
return {
ok: true,
value: {
kind: 'finish',
name: body.name,
objectKey: body.objectKey,
objectId: body.objectId,
uploadKey: body.uploadKey,
views: views as ('2d' | '3d')[],
masterViews: body.masterViews,
zipEntrypoint: body.zipEntrypoint,
},
}
}
return { ok: false, error: 'kind must be start or finish.' }
}
import { APS_BASE_URL, type TokenSource } from 'aec-auth'
import { apsOAuth, memoryVaultStore, vaultTokenSource } from 'aec-auth/vault'
import { connection } from 'next/server'
const VIEWER_SCOPES = ['viewables:read'] as const
const tokenSources = new Map<string, TokenSource>()
// Vercel sets VERCEL_PROJECT_PRODUCTION_URL on every deployment, so a one-click
// deploy has a trusted origin before its owner knows the URL.
function deploymentOrigin(): string | undefined {
if (process.env.APP_ORIGIN) return process.env.APP_ORIGIN
const vercel = process.env.VERCEL_PROJECT_PRODUCTION_URL
return vercel ? `https://${vercel}` : undefined
}
function apiBase(origin: string): string {
const configured = process.env.APS_AUTH_BASE_URL
if (!configured) return APS_BASE_URL
if (!configured.startsWith('/')) return configured
const trusted = deploymentOrigin()
if (!trusted && process.env.NODE_ENV === 'production') {
throw new Error('APP_ORIGIN is required with a relative APS_AUTH_BASE_URL in production')
}
return `${trusted ? new URL(trusted).origin : origin}${configured}`
}
function getViewerTokenSource(base: string): TokenSource {
const existing = tokenSources.get(base)
if (existing) return existing
const clientId = process.env.APS_CLIENT_ID
const clientSecret = process.env.APS_CLIENT_SECRET
if (!clientId || !clientSecret) throw new Error('APS viewer credentials are not configured')
const source = vaultTokenSource({
store: memoryVaultStore(),
providers: {
aps: apsOAuth({ clientId, clientSecret, baseUrl: base === APS_BASE_URL ? undefined : base }),
},
})
tokenSources.set(base, source)
return source
}
/** A 2-legged viewer token with the minimum Model Derivative read scope. */
export async function GET(request: Request): Promise<Response> {
await connection()
try {
const base = apiBase(new URL(request.url).origin)
const token = await getViewerTokenSource(base).getToken({
provider: 'aps',
subject: { type: 'app' },
scopes: VIEWER_SCOPES,
forceRefresh: true,
})
return Response.json(
{
accessToken: token.token,
expiresInSeconds: Math.max(1, Math.floor((token.expiresAt - Date.now()) / 1000)),
},
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error) {
console.error('Viewer token request failed', error)
return Response.json(
{ error: 'The viewer token is unavailable.' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}
}