From 1af06f7f83bb4eda08feeb49cc70cdd51efa380d Mon Sep 17 00:00:00 2001
From: pdparchitect
Date: Thu, 10 Sep 2026 23:08:42 +0000
Subject: [PATCH] chore: update version to 0.4.0 and add release date to
changelog (+3 more)
- chore: update version to 0.4.0 and add release date to changelog
- Refactor image generation and editing API
- fix: remove unnecessary separator in contributing links in README
- feat: update references from ChatBotKit Studio to CBK Studio in documentation
---
CHANGELOG.md | 43 ++-
README.md | 4 +-
docker/distro/community/compose.yml | 2 +-
docker/distro/studio/compose.yml | 2 +-
docs/deployment.md | 4 +-
package.json | 2 +-
.../(project)/[projectId]/components.jsx | 191 +++++-----
.../f2a9c7d4/(project)/[projectId]/server.ts | 327 +-----------------
platform/app/apps/(adhoc)/f2a9c7d4/README.md | 34 +-
platform/app/apps/(adhoc)/f2a9c7d4/api.ts | 306 ++++++++++++++++
.../app/apps/(adhoc)/f2a9c7d4/api.utest.js | 290 ++++++++++++++++
.../apps/(adhoc)/f2a9c7d4/api/assets/route.ts | 7 +
.../apps/(adhoc)/f2a9c7d4/api/edit/route.ts | 7 +
.../(adhoc)/f2a9c7d4/api/generate/route.ts | 7 +
.../apps/(adhoc)/f2a9c7d4/api/save/route.ts | 7 +
.../apps/(adhoc)/f2a9c7d4/api/upload/route.ts | 7 +
platform/app/apps/(adhoc)/f2a9c7d4/space.ts | 28 ++
platform/components/DynamicIcon.jsx | 12 +-
platform/components/DynamicIcon.utest.jsx | 57 ++-
platform/components/Link.jsx | 9 +-
platform/components/Link.utest.jsx | 25 ++
platform/config/models.ts | 84 ++---
platform/lib/app.route.ts | 57 ++-
platform/lib/app.route.utest.js | 110 +++++-
platform/lib/auth.adapter.ts | 19 +-
platform/lib/auth.adapter.utest.js | 38 ++
platform/lib/model.provider.vercel.utest.js | 195 ++++++++---
platform/lib/session.get.js | 19 +-
platform/lib/session.get.utest.js | 21 ++
platform/next.config.d/hmr.config.js | 16 +
platform/next.config.d/hmr.config.utest.js | 9 +
31 files changed, 1410 insertions(+), 529 deletions(-)
create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api.ts
create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js
create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts
create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts
create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts
create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts
create mode 100644 platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts
create mode 100644 platform/next.config.d/hmr.config.js
create mode 100644 platform/next.config.d/hmr.config.utest.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 280acd2..1c35d10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,11 +5,48 @@ here. The release version is defined in the workspace root `package.json`.
## [Unreleased]
+## [0.4.0] - 2026-09-10
+
+### Changed
+
+- Media Graph now generates, transforms, uploads and saves through route
+ handlers instead of server actions, so several nodes can generate at the
+ same time. Server actions are queued one at a time by the browser, which
+ made every generation wait for the previous one. The `appContactRouteHandler`
+ helper is the route counterpart of `appContactActionHandler`.
+
### Fixed
-- Bill DeepSeek V4.1 Flash through Vercel AI Gateway at the gateway's peak
- rate and full 1,048,576-token context, matching the gateway catalogue rather
- than DeepSeek's off-peak list price.
+- Resolve cookie sessions in App Router route handlers. `getSession` handed
+ next-auth the Web `Request`, which carries neither the `cookies` map nor
+ the plain `headers` object next-auth reads, so every cookie-authenticated
+ route handler answered 401. Those requests now resolve through
+ `next/headers`, the same way server actions already did.
+- Keep the dev server's Server Components HMR cache off. On a refresh after
+ a code change Next replayed every fetch of the render, keyed by URL and
+ body, and `cache: 'no-store'` does not opt a call out. The PlanetScale
+ driver's `BEGIN` request has the same body every time, so the driver was
+ handed the session of an already committed transaction and the following
+ statement failed with `transaction ... ended (transaction committed)`.
+ Development only; production never had the cache.
+- Stop a failed session expiry bump from signing the user out of a request.
+ With database sessions NextAuth refreshes the expiry once its update window
+ has passed and treated any failure of that update as a lost session, so App
+ Router pages bounced to sign-in and on to the overview. The adapter now
+ reports the failure and keeps the session, which was already read with a
+ valid expiry.
+- Stop the sidebar logo from reporting a hydration mismatch in dark mode.
+ The icon picked its dark variant from the theme on the first client render
+ while the server had rendered the light one, so React flagged the filter
+ style. The theme is now applied only after hydration.
+- Stop the App Router build of `next/link` warning about the `locale` prop.
+ The shared `Link` wrapper forwarded the default locale on every link; it
+ now forwards a locale only when a caller sets one.
+- Price multi-backend Vercel AI Gateway models at the most expensive backend
+ the gateway can route to. The gateway bills each request at the rate of the
+ backend it picks, so DeepSeek V4 Flash, DeepSeek V4 Pro, DeepSeek V4.1
+ Flash, Gemma 4 31B, GLM-5.2, GLM-5.3 Flash, MiMo V2.5, MiMo V2.5 Pro and
+ MiniMax M3 no longer bill below what the gateway can charge.
## [0.3.3] - 2026-09-10
diff --git a/README.md b/README.md
index 96da97d..4d22a9f 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@
Try it ·
Documentation ·
Architecture ·
- Contributing ·
+ Contributing
@@ -75,7 +75,7 @@ docker compose -f oci://ghcr.io/chatbotkit/platform-community:latest logs platfo
See [Deployment](./docs/deployment.md) for details.
On an Apple silicon Mac you can skip Docker altogether:
-[ChatBotKit Studio](https://github.com/chatbotkit/studio) is a native macOS
+[CBK Studio](https://github.com/chatbotkit/studio) is a native macOS
app that runs this same platform in an app-private VM, with model providers,
logs and updates managed from the app.
diff --git a/docker/distro/community/compose.yml b/docker/distro/community/compose.yml
index fad788c..73e3d62 100644
--- a/docker/distro/community/compose.yml
+++ b/docker/distro/community/compose.yml
@@ -23,7 +23,7 @@
# (and with it the app shells, spaces and portals), RELAY_PORT the realtime
# relay and STORAGE_PORT the object store, and every address a browser or a
# host process dials is declared once in the `x-cbk` manifest below. A
-# launcher such as ChatBotKit Studio reads the resolved manifest back with
+# launcher such as CBK Studio reads the resolved manifest back with
# `docker compose config --format json` rather than assuming port 3000.
#
# One folder per package flavor lives under docker/distro/. Each publishes as
diff --git a/docker/distro/studio/compose.yml b/docker/distro/studio/compose.yml
index af82059..953e42e 100644
--- a/docker/distro/studio/compose.yml
+++ b/docker/distro/studio/compose.yml
@@ -23,7 +23,7 @@
# (and with it the app shells, spaces and portals), RELAY_PORT the realtime
# relay and STORAGE_PORT the object store, and every address a browser or a
# host process dials is declared once in the `x-cbk` manifest below. A
-# launcher such as ChatBotKit Studio reads the resolved manifest back with
+# launcher such as CBK Studio reads the resolved manifest back with
# `docker compose config --format json` rather than assuming a port.
#
# Studio publishes on 31000, 31001 and 31900 rather than Community's 3000
diff --git a/docs/deployment.md b/docs/deployment.md
index 5b817c8..d08e857 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -189,7 +189,7 @@ PLATFORM_HOST=studio.localhost \
}
```
-A launcher such as ChatBotKit Studio therefore never assumes a port: it
+A launcher such as CBK Studio therefore never assumes a port: it
keeps the flavor's defaults or sets `PLATFORM_PORT`, `RELAY_PORT` and
`STORAGE_PORT` to free ones (and `PLATFORM_HOST` or any endpoint's variable to
rename a host), reads the resolved manifest back and forwards, opens and trusts exactly the addresses
@@ -233,7 +233,7 @@ its workspaces kept under `/data/sandbox` in the same volume.
Studio starts as a copy of Community, with the same Docker build targets,
module defaults and services. It is the flavor embedded by
-[ChatBotKit Studio](https://github.com/chatbotkit/studio), the native macOS
+[CBK Studio](https://github.com/chatbotkit/studio), the native macOS
app that runs the platform in an app-private VM without a Docker install, and
can also be run directly with Compose. Its separate Compose file lives at
[docker/distro/studio/compose.yml](../docker/distro/studio/compose.yml), and the
diff --git a/package.json b/package.json
index 66cf6f5..5675b3b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "platform",
- "version": "0.3.3",
+ "version": "0.4.0",
"private": true,
"license": "Apache-2.0",
"packageManager": "pnpm@11.24.0",
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx b/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx
index 75751b7..1d90e1e 100644
--- a/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/(project)/[projectId]/components.jsx
@@ -23,8 +23,6 @@ import {
LuX,
} from 'react-icons/lu'
-import { errorToErrorResponse } from '@/lib/error'
-import { throwUnprocessableEntity } from '@/lib/response'
import { saveUrl } from '@/lib/save'
import toast from '@/lib/toast'
@@ -33,6 +31,7 @@ import { AppNavExtra } from '@/layouts/App'
import { useConfirmYesNo } from '@/components/Confirm'
import ImageModelSelect from '@/components/ImageModelSelect'
+import useFetch from '@/hooks/useFetch'
import useHistory from '@/hooks/useHistory'
import usePreventLeave from '@/hooks/usePreventLeave'
import useRouter from '@/hooks/useRouter'
@@ -40,13 +39,6 @@ import useTheme from '@/hooks/useTheme'
import { APP_NAME } from '../../const'
import { DEFAULT_MODEL, DEFAULT_SIZE, SIZES } from '../../lib'
-import {
- createAssetUpload,
- editImageNode,
- generateImage,
- getAssetUrls,
- saveProject,
-} from './server'
import {
Background,
@@ -89,12 +81,52 @@ function NavIconButton({
)
}
+const API_BASE = `/apps/${APP_NAME}/api`
+
+/**
+ * Returns a caller for the editor route handlers (see `../../api`). Each call
+ * is an independent request, so several nodes can generate at the same time -
+ * server actions would have been queued one after another by the client.
+ */
+function useApi() {
+ const { fetch } = useFetch({ trackLoading: false, trackStreaming: false })
+
+ return useCallback(
+ async (op, body, options = {}) => {
+ const { data, error } = await fetch(`${API_BASE}/${op}`, {
+ data: body,
+ headers: { Accept: 'application/json' },
+ // @note useFetch dismisses its toast id when done and an undefined id
+ // would dismiss every toast, including other nodes' progress
+ toastId: `${API_BASE}/${op}`,
+ ...options,
+ })
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ // @note a long-running call is answered as a 200 stream whose body may
+ // carry the error envelope instead of the result
+ if (
+ data &&
+ typeof data.code === 'string' &&
+ typeof data.message === 'string'
+ ) {
+ throw new Error(data.message)
+ }
+
+ return data
+ },
+ [fetch]
+ )
+}
+
/**
* Context that exposes node-level callbacks and editor metadata to the custom
* ReactFlow node, which only otherwise receives its own `data`.
*/
const EditorContext = createContext({
- busy: false,
dragging: false,
endDrag: () => {},
zoomImage: () => {},
@@ -167,7 +199,6 @@ function makeImageNode(position) {
/** A single image node: prompt, model/size controls and the generated image. */
function ImageNode({ id, data }) {
const {
- busy,
dragging,
endDrag,
zoomImage,
@@ -269,7 +300,6 @@ function ImageNode({ id, data }) {
{/* preview */}
{data.assetUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
generateNode(id)}
>
{sourceCount > 0 ? 'Transform' : 'Generate'}
@@ -399,6 +429,8 @@ function Flow({ project, assetUrls }) {
const confirmYesNo = useConfirmYesNo()
+ const api = useApi()
+
const toRfNode = useCallback(
(node) => ({
id: node.id,
@@ -429,7 +461,6 @@ function Flow({ project, assetUrls }) {
project.edges || []
)
- const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [paneDragActive, setPaneDragActive] = useState(false)
const [minimap, setMinimap] = useState(true)
@@ -670,13 +701,18 @@ function Flow({ project, assetUrls }) {
const updateNodeData = useCallback(
(nodeId, patch) => {
- setNodes((current) =>
+ const apply = (current) =>
current.map((node) =>
node.id === nodeId
? { ...node, data: { ...node.data, ...patch } }
: node
)
- )
+
+ // @note also patch the live ref so a persist issued right after (before
+ // React re-renders) already sees the change
+ nodesRef.current = apply(nodesRef.current)
+
+ setNodes(apply)
},
[setNodes]
)
@@ -781,27 +817,27 @@ function Flow({ project, assetUrls }) {
setNodes((current) => [...current, makeImageNode(visibleCenterPosition())])
}, [setNodes, visibleCenterPosition, pushHistory])
+ // @note saves run one after another; concurrent generations each persist on
+ // completion and an earlier, staler payload must not land after a newer one
+ const persistQueueRef = useRef(Promise.resolve())
+
/** Serializes the current graph (without transient fields) and persists it. */
- const persist = useCallback(async () => {
- const payload = buildGraphPayload(nodesRef.current, edgesRef.current)
+ const persist = useCallback(() => {
+ const run = persistQueueRef.current
+ .catch(() => {})
+ .then(async () => {
+ const payload = buildGraphPayload(nodesRef.current, edgesRef.current)
- const result = await saveProject({
- projectId,
- nodes: payload.nodes,
- edges: payload.edges,
- })
+ await api('save', { projectId, ...payload })
- if (!result) {
- return throwUnprocessableEntity('Unexpected action result')
- }
+ // @note remember what we saved so the unsaved-changes indicator clears
+ setSavedSnapshot(JSON.stringify(payload))
+ })
- if ('error' in result) {
- throw errorToErrorResponse(result.error)
- }
+ persistQueueRef.current = run
- // @note remember what we saved so the unsaved-changes indicator clears
- setSavedSnapshot(JSON.stringify(payload))
- }, [projectId])
+ return run
+ }, [api, projectId])
const onSave = useCallback(async () => {
setSaving(true)
@@ -845,34 +881,34 @@ function Flow({ project, assetUrls }) {
pendingLabel: activity,
error: undefined,
})
- setBusy(true)
const toastId = toast.loading(activity, {})
try {
+ // @note never retry a generation - a retried request would bill twice
const result =
sourceAssetPaths.length > 0
- ? await editImageNode({
- projectId,
- prompt: node.data.prompt,
- sourceAssetPaths,
- model: node.data.model,
- size: node.data.size,
- })
- : await generateImage({
- projectId,
- prompt: node.data.prompt,
- model: node.data.model,
- size: node.data.size,
- })
-
- if (!result) {
- return throwUnprocessableEntity('Unexpected action result')
- }
-
- if ('error' in result) {
- throw errorToErrorResponse(result.error)
- }
+ ? await api(
+ 'edit',
+ {
+ projectId,
+ prompt: node.data.prompt,
+ sourceAssetPaths,
+ model: node.data.model,
+ size: node.data.size,
+ },
+ { retries: 0 }
+ )
+ : await api(
+ 'generate',
+ {
+ projectId,
+ prompt: node.data.prompt,
+ model: node.data.model,
+ size: node.data.size,
+ },
+ { retries: 0 }
+ )
updateNodeData(nodeId, {
assetPath: result.assetPath,
@@ -890,11 +926,9 @@ function Flow({ project, assetUrls }) {
updateNodeData(nodeId, { status: 'error', error: e.message })
toast.error(e.message, { id: toastId })
- } finally {
- setBusy(false)
}
},
- [projectId, updateNodeData, persist, pushHistory]
+ [api, projectId, updateNodeData, persist, pushHistory]
)
/**
@@ -903,18 +937,14 @@ function Flow({ project, assetUrls }) {
*/
const uploadFile = useCallback(
async (file) => {
- const meta = await createAssetUpload({
- projectId,
- file: { type: file.type, size: file.size },
- })
-
- if (!meta) {
- return throwUnprocessableEntity('Unexpected action result')
- }
-
- if ('error' in meta) {
- throw errorToErrorResponse(meta.error)
- }
+ const meta = await api(
+ 'upload',
+ {
+ projectId,
+ file: { type: file.type, size: file.size },
+ },
+ { retries: 0 }
+ )
if (!meta.uploadRequest) {
throw new Error('Upload is not available')
@@ -932,14 +962,14 @@ function Flow({ project, assetUrls }) {
throw new Error('Failed to upload image')
}
- const urls = await getAssetUrls({ projectId, paths: [meta.path] })
-
- const assetUrl =
- urls && !('error' in urls) ? urls.assetUrls[meta.path] : undefined
+ const { assetUrls } = await api('assets', {
+ projectId,
+ paths: [meta.path],
+ })
- return { assetPath: meta.path, assetUrl }
+ return { assetPath: meta.path, assetUrl: assetUrls?.[meta.path] }
},
- [projectId]
+ [api, projectId]
)
/** Creates a new node from a dropped image file at the given flow position. */
@@ -971,8 +1001,6 @@ function Flow({ project, assetUrls }) {
},
])
- setBusy(true)
-
const toastId = toast.loading('Uploading image…', {})
try {
@@ -992,8 +1020,6 @@ function Flow({ project, assetUrls }) {
setNodes((current) => current.filter((node) => node.id !== nodeId))
toast.error(e.message, { id: toastId })
- } finally {
- setBusy(false)
}
},
[setNodes, uploadFile, updateNodeData, persist, pushHistory]
@@ -1014,7 +1040,6 @@ function Flow({ project, assetUrls }) {
pendingLabel: 'Uploading…',
error: undefined,
})
- setBusy(true)
const toastId = toast.loading('Uploading image…', {})
@@ -1036,8 +1061,6 @@ function Flow({ project, assetUrls }) {
updateNodeData(nodeId, { status: 'error', error: e.message })
toast.error(e.message, { id: toastId })
- } finally {
- setBusy(false)
}
},
[updateNodeData, uploadFile, persist, pushHistory]
@@ -1204,7 +1227,6 @@ function Flow({ project, assetUrls }) {
const contextValue = useMemo(
() => ({
- busy,
dragging: paneDragActive,
endDrag,
zoomImage,
@@ -1215,7 +1237,6 @@ function Flow({ project, assetUrls }) {
setNodeImageFromFile,
}),
[
- busy,
paneDragActive,
endDrag,
zoomImage,
@@ -1351,7 +1372,7 @@ function Flow({ project, assetUrls }) {
>
- {/* eslint-disable-next-line @next/next/no-img-element */}
+ {}
> {
- const entries = await Promise.all(
- [...new Set(paths)].map(async (path) => {
- try {
- const { url } = await client.space.storage.download(spaceId, path)
-
- return [path, url] as const
- } catch {
- return null
- }
- })
- )
-
- return Object.fromEntries(
- entries.filter((entry): entry is readonly [string, string] => entry !== null)
- )
-}
-
-/** Ensures the account is within limits before spending on image generation. */
-async function assertWithinLimits(session: Session): Promise {
- const user = await fastGetUserById(session.user.id)
-
- if (!user) {
- throw new Error('User not found')
- }
-
- if (!(await accountLimitsOk(user, ['token', 'image']))) {
- throw new Error('You have reached your usage limit.')
- }
-}
-
-/** Records token and image usage for a single generation, mirroring the API. */
-async function recordUsage(
- session: Session,
- usage: { inputTokens: number; outputTokens: number; model: string },
- count: number,
- reason: 'image/create' | 'image/edit'
-): Promise {
- const usageRecorder = new Usage()
-
- usageRecorder.addImageTokens(usage.inputTokens, usage.model, 'input')
- usageRecorder.addImageTokens(usage.outputTokens, usage.model, 'output')
-
- await usageRecorder.recordBaseTokens({
- user: { id: session.user.id },
- meta: { reason },
- })
-
- await recordImageUsage({
- user: { id: session.user.id },
- count,
- model: usage.model,
- meta: { reason },
- })
-}
+import { buildAssetUrls, ensureSpace, readProject } from '../../space'
/**
* Loads a project graph along with freshly signed URLs for every asset
* referenced by its nodes.
+ *
+ * @note this is the only editor operation left as a server action: the page
+ * calls it during server rendering. Everything the editor triggers from the
+ * browser goes through the route handlers in `../../api` so requests can run
+ * concurrently.
*/
export const getProject = appContactActionHandler(
APP_NAME,
@@ -144,221 +47,3 @@ export const getProject = appContactActionHandler(
return { project, assetUrls }
}
)
-
-/** Persists the full graph (nodes + edges) for a project. */
-export const saveProject = appContactActionHandler(
- APP_NAME,
- CONTACT_NAMESPACE,
- ConfigSchema,
- z.object({
- projectId: z.string(),
- nodes: z.array(NodeSchema),
- edges: z.array(EdgeSchema),
- }),
- async (
- _config,
- session,
- contact,
- { projectId, nodes, edges }
- ): Promise<{ id: string }> => {
- const { client, spaceId } = await ensureSpace(session, contact)
-
- const project = await readProject(client, spaceId, projectId)
-
- if (!project) {
- throw new Error('Project not found')
- }
-
- await writeProject(client, spaceId, {
- ...project,
- nodes,
- edges,
- updatedAt: new Date().toISOString(),
- })
-
- return { id: projectId }
- }
-)
-
-/**
- * Refreshes presigned download URLs for the requested asset paths. Used when
- * URLs returned by `getProject` expire during a long editing session.
- */
-export const getAssetUrls = appContactActionHandler(
- APP_NAME,
- CONTACT_NAMESPACE,
- ConfigSchema,
- z.object({
- projectId: z.string(),
- paths: z.array(z.string()),
- }),
- async (
- _config,
- session,
- contact,
- { paths }
- ): Promise<{ assetUrls: Record }> => {
- const { client, spaceId } = await ensureSpace(session, contact)
-
- const assetUrls = await buildAssetUrls(client, spaceId, paths)
-
- return { assetUrls }
- }
-)
-
-/**
- * Mints a presigned upload request for a dropped image file. The browser PUTs
- * the bytes directly to storage, so the file never passes through the server
- * action body. Returns the asset path and the upload request to perform.
- */
-export const createAssetUpload = appContactActionHandler(
- APP_NAME,
- CONTACT_NAMESPACE,
- ConfigSchema,
- z.object({
- projectId: z.string(),
- file: z.object({
- type: z.string(),
- size: z.number(),
- }),
- }),
- async (
- _config,
- session,
- contact,
- { projectId, file }
- ): Promise<{
- path: string
- uploadRequest?: { method: string; url: string; headers: object }
- }> => {
- const { client, spaceId } = await ensureSpace(session, contact)
-
- const path = assetPath(projectId, randomUUID(), extFromMimeType(file.type))
-
- const data = await client.space.storage.upload(spaceId, path, {
- file: {
- type: file.type || 'application/octet-stream',
- size: file.size,
- },
- })
-
- return { path: data.path, uploadRequest: data.uploadRequest }
- }
-)
-
-/**
- * Generates a brand new image from a prompt and stores it as an asset in the
- * project's assets folder. Returns the stored path and a download URL.
- */
-export const generateImage = appContactActionHandler(
- APP_NAME,
- CONTACT_NAMESPACE,
- ConfigSchema,
- z.object({
- projectId: z.string(),
- prompt: z.string().min(1),
- model: z.string().optional(),
- size: z.string().optional(),
- }),
- async (
- _config,
- session,
- contact,
- { projectId, prompt, model, size }
- ): Promise<{ assetPath: string; assetUrl: string }> => {
- const { client, spaceId } = await ensureSpace(session, contact)
-
- await assertWithinLimits(session)
-
- const { urls, usage } = await createImage(prompt, {
- model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE),
- user: session.user.id,
- })
-
- await recordUsage(session, usage, urls.length, 'image/create')
-
- const [url] = urls
-
- if (!url) {
- throw new Error('Image generation returned no result')
- }
-
- return storeAsset(client, spaceId, projectId, url)
- }
-)
-
-/**
- * Creates a new image by transforming one or more source images with a prompt
- * (the `image/edit` flow). The new image is stored as a fresh asset.
- */
-export const editImageNode = appContactActionHandler(
- APP_NAME,
- CONTACT_NAMESPACE,
- ConfigSchema,
- z.object({
- projectId: z.string(),
- prompt: z.string().min(1),
- sourceAssetPaths: z.array(z.string()).min(1),
- model: z.string().optional(),
- size: z.string().optional(),
- }),
- async (
- _config,
- session,
- contact,
- { projectId, prompt, sourceAssetPaths, model, size }
- ): Promise<{ assetPath: string; assetUrl: string }> => {
- const { client, spaceId } = await ensureSpace(session, contact)
-
- await assertWithinLimits(session)
-
- // @note fetch each source asset as a blob to feed the edit model
- const imageBlobs = await Promise.all(
- sourceAssetPaths.map(async (path) => {
- const { url } = await client.space.storage.download(spaceId, path)
-
- const response = await fetch(url)
-
- if (!response.ok) {
- throw new Error('Failed to load a source image')
- }
-
- return response.blob()
- })
- )
-
- const { urls, usage } = await editImage(prompt, imageBlobs, {
- model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE),
- user: session.user.id,
- })
-
- await recordUsage(session, usage, urls.length, 'image/edit')
-
- const [url] = urls
-
- if (!url) {
- throw new Error('Image edit returned no result')
- }
-
- return storeAsset(client, spaceId, projectId, url)
- }
-)
-
-/**
- * Ingests a generated image URL into the project's assets folder and returns
- * the stored path together with a download URL for immediate display.
- */
-async function storeAsset(
- client: Client,
- spaceId: string,
- projectId: string,
- url: string
-): Promise<{ assetPath: string; assetUrl: string }> {
- const path = assetPath(projectId, randomUUID())
-
- await client.space.storage.upload(spaceId, path, { file: url })
-
- const { url: assetUrl } = await client.space.storage.download(spaceId, path)
-
- return { assetPath: path, assetUrl }
-}
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/README.md b/platform/app/apps/(adhoc)/f2a9c7d4/README.md
index 23454ee..a394cbe 100644
--- a/platform/app/apps/(adhoc)/f2a9c7d4/README.md
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/README.md
@@ -11,17 +11,31 @@ Compose AI images on a canvas - generate from prompts and transform images into
```
f2a9c7d4/
- app.manifest # App configuration
- const.ts # APP_NAME and CONTACT_NAMESPACE exports
- config.ts # Zod schema for app configuration
- layout.jsx # App layout with standard header/footer
- page.tsx # Server component for initial data loading
- components.jsx # Client components
- server.ts # Server-side logic
- README.md # This file
+ app.manifest # App configuration
+ const.ts # APP_NAME, CONTACT_NAMESPACE and storage layout
+ config.ts # Zod schema for app configuration
+ lib.ts # Shared types and pure helpers (client-safe)
+ space.ts # Server-only space and project storage helpers
+ server.ts # Project list actions (list, create, update, delete)
+ api.ts # Editor route handlers (save, assets, upload, generate, edit)
+ api//route.ts # POST endpoints exposing the handlers in api.ts
+ layout.jsx # Minimal root layout
+ components.jsx # Project list client components
+ (home)/ # Project list route with the standard app chrome
+ (project)/[projectId]/ # Fullscreen canvas editor
+ server.ts # getProject action used by the page render
+ components.jsx # ReactFlow editor calling the api routes
+ README.md # This file
```
## Key Behavior
-- Exposes Media Graph functionality through the ChatBotKit platform interface
-- Provides comprehensive features for managing and working with Media Graph
+- Each user gets a space (aliased by app and contact fingerprint) holding one
+ folder per project with a `project.json` graph and an `assets` folder
+- The project list uses server actions; the editor calls the route handlers
+ under `/apps/f2a9c7d4/api/*` instead. Server actions are queued one at a
+ time by the client, which serialised image generation across nodes. Route
+ handlers run as independent requests, so several nodes can generate at once
+- The editor saves through a client-side queue so a slower save issued
+ earlier cannot overwrite a newer graph
+- Image generation and edit calls are never retried to avoid billing twice
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api.ts
new file mode 100644
index 0000000..4124d0b
--- /dev/null
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/api.ts
@@ -0,0 +1,306 @@
+import type { Session } from 'next-auth'
+
+import { appContactRouteHandler } from '@/lib/app.route'
+import fetch from '@/lib/fetch'
+import { createImage, editImage } from '@/lib/image'
+import { accountLimitsOk } from '@/lib/limit.core'
+import { Usage } from '@/lib/usage.model'
+import { recordImageUsage } from '@/lib/usage.record'
+import { fastGetUserById } from '@/lib/user.get'
+import { z } from '@/lib/zod.schema'
+
+import ConfigSchema from './config'
+import { APP_NAME, CONTACT_NAMESPACE } from './const'
+import {
+ DEFAULT_MODEL,
+ DEFAULT_SIZE,
+ assetPath,
+ buildModelId,
+ extFromMimeType,
+} from './lib'
+import type { Client } from './space'
+import { buildAssetUrls, ensureSpace, readProject, writeProject } from './space'
+
+import { randomUUID } from 'crypto'
+
+/**
+ * @file Route handlers for the editor operations. These run as independent
+ * HTTP requests so several image generations can be in flight at once, which
+ * server actions cannot do because the client serialises them.
+ */
+
+const NodeSchema = z.object({
+ id: z.string(),
+ type: z.literal('image'),
+ position: z.object({ x: z.number(), y: z.number() }),
+ data: z.object({
+ prompt: z.string(),
+ model: z.string(),
+ size: z.string(),
+ assetPath: z.string().nullable(),
+ status: z.enum(['empty', 'pending', 'ready', 'error']),
+ error: z.string().optional(),
+ }),
+})
+
+const EdgeSchema = z.object({
+ id: z.string(),
+ source: z.string(),
+ target: z.string(),
+})
+
+/** Ensures the account is within limits before spending on image generation. */
+async function assertWithinLimits(session: Session): Promise {
+ const user = await fastGetUserById(session.user.id)
+
+ if (!user) {
+ throw new Error('User not found')
+ }
+
+ if (!(await accountLimitsOk(user, ['token', 'image']))) {
+ throw new Error('You have reached your usage limit.')
+ }
+}
+
+/** Records token and image usage for a single generation, mirroring the API. */
+async function recordUsage(
+ session: Session,
+ usage: { inputTokens: number; outputTokens: number; model: string },
+ count: number,
+ reason: 'image/create' | 'image/edit'
+): Promise {
+ const usageRecorder = new Usage()
+
+ usageRecorder.addImageTokens(usage.inputTokens, usage.model, 'input')
+ usageRecorder.addImageTokens(usage.outputTokens, usage.model, 'output')
+
+ await usageRecorder.recordBaseTokens({
+ user: { id: session.user.id },
+ meta: { reason },
+ })
+
+ await recordImageUsage({
+ user: { id: session.user.id },
+ count,
+ model: usage.model,
+ meta: { reason },
+ })
+}
+
+/**
+ * Ingests a generated image URL into the project's assets folder and returns
+ * the stored path together with a download URL for immediate display.
+ */
+async function storeAsset(
+ client: Client,
+ spaceId: string,
+ projectId: string,
+ url: string
+): Promise<{ assetPath: string; assetUrl: string }> {
+ const path = assetPath(projectId, randomUUID())
+
+ await client.space.storage.upload(spaceId, path, { file: url })
+
+ const { url: assetUrl } = await client.space.storage.download(spaceId, path)
+
+ return { assetPath: path, assetUrl }
+}
+
+/** Persists the full graph (nodes + edges) for a project. */
+export const saveProject = appContactRouteHandler(
+ APP_NAME,
+ CONTACT_NAMESPACE,
+ ConfigSchema,
+ z.object({
+ projectId: z.string(),
+ nodes: z.array(NodeSchema),
+ edges: z.array(EdgeSchema),
+ }),
+ async (
+ _config,
+ session,
+ contact,
+ { projectId, nodes, edges }
+ ): Promise<{ id: string }> => {
+ const { client, spaceId } = await ensureSpace(session, contact)
+
+ const project = await readProject(client, spaceId, projectId)
+
+ if (!project) {
+ throw new Error('Project not found')
+ }
+
+ await writeProject(client, spaceId, {
+ ...project,
+ nodes,
+ edges,
+ updatedAt: new Date().toISOString(),
+ })
+
+ return { id: projectId }
+ }
+)
+
+/**
+ * Refreshes presigned download URLs for the requested asset paths. Used when
+ * URLs returned by `getProject` expire during a long editing session.
+ */
+export const getAssetUrls = appContactRouteHandler(
+ APP_NAME,
+ CONTACT_NAMESPACE,
+ ConfigSchema,
+ z.object({
+ projectId: z.string(),
+ paths: z.array(z.string()),
+ }),
+ async (
+ _config,
+ session,
+ contact,
+ { paths }
+ ): Promise<{ assetUrls: Record }> => {
+ const { client, spaceId } = await ensureSpace(session, contact)
+
+ const assetUrls = await buildAssetUrls(client, spaceId, paths)
+
+ return { assetUrls }
+ }
+)
+
+/**
+ * Mints a presigned upload request for a dropped image file. The browser PUTs
+ * the bytes directly to storage, so the file never passes through the server.
+ * Returns the asset path and the upload request to perform.
+ */
+export const createAssetUpload = appContactRouteHandler(
+ APP_NAME,
+ CONTACT_NAMESPACE,
+ ConfigSchema,
+ z.object({
+ projectId: z.string(),
+ file: z.object({
+ type: z.string(),
+ size: z.number(),
+ }),
+ }),
+ async (
+ _config,
+ session,
+ contact,
+ { projectId, file }
+ ): Promise<{
+ path: string
+ uploadRequest?: { method: string; url: string; headers: object }
+ }> => {
+ const { client, spaceId } = await ensureSpace(session, contact)
+
+ const path = assetPath(projectId, randomUUID(), extFromMimeType(file.type))
+
+ const data = await client.space.storage.upload(spaceId, path, {
+ file: {
+ type: file.type || 'application/octet-stream',
+ size: file.size,
+ },
+ })
+
+ return { path: data.path, uploadRequest: data.uploadRequest }
+ }
+)
+
+/**
+ * Generates a brand new image from a prompt and stores it as an asset in the
+ * project's assets folder. Returns the stored path and a download URL.
+ */
+export const generateImage = appContactRouteHandler(
+ APP_NAME,
+ CONTACT_NAMESPACE,
+ ConfigSchema,
+ z.object({
+ projectId: z.string(),
+ prompt: z.string().min(1),
+ model: z.string().optional(),
+ size: z.string().optional(),
+ }),
+ async (
+ _config,
+ session,
+ contact,
+ { projectId, prompt, model, size }
+ ): Promise<{ assetPath: string; assetUrl: string }> => {
+ const { client, spaceId } = await ensureSpace(session, contact)
+
+ await assertWithinLimits(session)
+
+ const { urls, usage } = await createImage(prompt, {
+ model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE),
+ user: session.user.id,
+ })
+
+ await recordUsage(session, usage, urls.length, 'image/create')
+
+ const [url] = urls
+
+ if (!url) {
+ throw new Error('Image generation returned no result')
+ }
+
+ return storeAsset(client, spaceId, projectId, url)
+ }
+)
+
+/**
+ * Creates a new image by transforming one or more source images with a prompt
+ * (the `image/edit` flow). The new image is stored as a fresh asset.
+ */
+export const editImageNode = appContactRouteHandler(
+ APP_NAME,
+ CONTACT_NAMESPACE,
+ ConfigSchema,
+ z.object({
+ projectId: z.string(),
+ prompt: z.string().min(1),
+ sourceAssetPaths: z.array(z.string()).min(1),
+ model: z.string().optional(),
+ size: z.string().optional(),
+ }),
+ async (
+ _config,
+ session,
+ contact,
+ { projectId, prompt, sourceAssetPaths, model, size }
+ ): Promise<{ assetPath: string; assetUrl: string }> => {
+ const { client, spaceId } = await ensureSpace(session, contact)
+
+ await assertWithinLimits(session)
+
+ // @note fetch each source asset as a blob to feed the edit model
+ const imageBlobs = await Promise.all(
+ sourceAssetPaths.map(async (path) => {
+ const { url } = await client.space.storage.download(spaceId, path)
+
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ throw new Error('Failed to load a source image')
+ }
+
+ return response.blob()
+ })
+ )
+
+ const { urls, usage } = await editImage(prompt, imageBlobs, {
+ model: buildModelId(model || DEFAULT_MODEL, size || DEFAULT_SIZE),
+ user: session.user.id,
+ })
+
+ await recordUsage(session, usage, urls.length, 'image/edit')
+
+ const [url] = urls
+
+ if (!url) {
+ throw new Error('Image edit returned no result')
+ }
+
+ return storeAsset(client, spaceId, projectId, url)
+ }
+)
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js b/platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js
new file mode 100644
index 0000000..e73c5e1
--- /dev/null
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/api.utest.js
@@ -0,0 +1,290 @@
+import fetch from '@/lib/fetch'
+import { createImage, editImage } from '@/lib/image'
+import { accountLimitsOk } from '@/lib/limit.core'
+import { recordImageUsage } from '@/lib/usage.record'
+import { fastGetUserById } from '@/lib/user.get'
+
+import {
+ createAssetUpload,
+ editImageNode,
+ generateImage,
+ getAssetUrls,
+ saveProject,
+} from './api'
+import { buildAssetUrls, ensureSpace, readProject, writeProject } from './space'
+
+// @note the route handler is reduced to a passthrough so each handler can be
+// invoked with its input directly; the session/contact plumbing has its own
+// tests in lib/app.route.utest.js
+jest.mock('@/lib/app.route', () => ({
+ appContactRouteHandler:
+ (_app, _namespace, _configSchema, inputSchema, handler) =>
+ async (input) => {
+ return handler(
+ {},
+ { user: { id: 'user-123' } },
+ { id: 'contact-123' },
+ await inputSchema.parseAsync(input),
+ {}
+ )
+ },
+}))
+
+jest.mock('crypto', () => ({
+ randomUUID: jest.fn(() => 'uuid-1'),
+}))
+
+jest.mock('@/lib/fetch', () => jest.fn())
+
+jest.mock('@/lib/image', () => ({
+ createImage: jest.fn(),
+ editImage: jest.fn(),
+}))
+
+jest.mock('@/lib/limit.core', () => ({
+ accountLimitsOk: jest.fn(),
+}))
+
+const mockUsage = {
+ addImageTokens: jest.fn(),
+ recordBaseTokens: jest.fn(),
+}
+
+jest.mock('@/lib/usage.model', () => ({
+ Usage: jest.fn(() => mockUsage),
+}))
+
+jest.mock('@/lib/usage.record', () => ({
+ recordImageUsage: jest.fn(),
+}))
+
+jest.mock('@/lib/user.get', () => ({
+ fastGetUserById: jest.fn(),
+}))
+
+jest.mock('./space', () => ({
+ buildAssetUrls: jest.fn(),
+ ensureSpace: jest.fn(),
+ readProject: jest.fn(),
+ writeProject: jest.fn(),
+}))
+
+describe('f2a9c7d4/api', () => {
+ let client
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ client = {
+ space: {
+ storage: {
+ upload: jest.fn().mockResolvedValue({
+ path: 'projects/p1/assets/uuid-1.png',
+ uploadRequest: { method: 'PUT', url: 'https://put', headers: {} },
+ }),
+ download: jest
+ .fn()
+ .mockImplementation(async (_spaceId, path) => ({
+ url: `https://get/${path}`,
+ })),
+ },
+ },
+ }
+
+ ensureSpace.mockResolvedValue({ client, spaceId: 'space-1' })
+ fastGetUserById.mockResolvedValue({ id: 'user-123' })
+ accountLimitsOk.mockResolvedValue(true)
+ })
+
+ describe('generateImage', () => {
+ it('generates, records usage and stores the asset', async () => {
+ createImage.mockResolvedValue({
+ urls: ['https://generated/1.png'],
+ usage: { inputTokens: 10, outputTokens: 20, model: 'gpt-image-2' },
+ })
+
+ const result = await generateImage({
+ projectId: 'p1',
+ prompt: 'a cat',
+ model: 'gpt-image-2',
+ size: '1024x1024',
+ })
+
+ expect(accountLimitsOk).toHaveBeenCalledWith({ id: 'user-123' }, [
+ 'token',
+ 'image',
+ ])
+ expect(createImage).toHaveBeenCalledWith('a cat', {
+ model: 'gpt-image-2/size=1024x1024',
+ user: 'user-123',
+ })
+ expect(mockUsage.recordBaseTokens).toHaveBeenCalledWith({
+ user: { id: 'user-123' },
+ meta: { reason: 'image/create' },
+ })
+ expect(recordImageUsage).toHaveBeenCalledWith({
+ user: { id: 'user-123' },
+ count: 1,
+ model: 'gpt-image-2',
+ meta: { reason: 'image/create' },
+ })
+ expect(client.space.storage.upload).toHaveBeenCalledWith(
+ 'space-1',
+ 'projects/p1/assets/uuid-1.png',
+ { file: 'https://generated/1.png' }
+ )
+ expect(result).toEqual({
+ assetPath: 'projects/p1/assets/uuid-1.png',
+ assetUrl: 'https://get/projects/p1/assets/uuid-1.png',
+ })
+ })
+
+ it('refuses to generate when the account is over its limits', async () => {
+ accountLimitsOk.mockResolvedValue(false)
+
+ await expect(
+ generateImage({ projectId: 'p1', prompt: 'a cat' })
+ ).rejects.toThrow('You have reached your usage limit.')
+
+ expect(createImage).not.toHaveBeenCalled()
+ })
+
+ it('rejects an empty prompt at the schema boundary', async () => {
+ await expect(
+ generateImage({ projectId: 'p1', prompt: '' })
+ ).rejects.toThrow()
+
+ expect(createImage).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('editImageNode', () => {
+ it('feeds every source asset to the edit model', async () => {
+ const blob = { size: 3 }
+
+ fetch.mockResolvedValue({ ok: true, blob: async () => blob })
+
+ editImage.mockResolvedValue({
+ urls: ['https://generated/2.png'],
+ usage: { inputTokens: 1, outputTokens: 2, model: 'gpt-image-2' },
+ })
+
+ const result = await editImageNode({
+ projectId: 'p1',
+ prompt: 'make it blue',
+ sourceAssetPaths: ['projects/p1/assets/a.png', 'projects/p1/assets/b.png'],
+ })
+
+ expect(client.space.storage.download).toHaveBeenCalledWith(
+ 'space-1',
+ 'projects/p1/assets/a.png'
+ )
+ expect(client.space.storage.download).toHaveBeenCalledWith(
+ 'space-1',
+ 'projects/p1/assets/b.png'
+ )
+ expect(editImage).toHaveBeenCalledWith('make it blue', [blob, blob], {
+ model: 'gpt-image-2',
+ user: 'user-123',
+ })
+ expect(recordImageUsage).toHaveBeenCalledWith(
+ expect.objectContaining({ meta: { reason: 'image/edit' } })
+ )
+ expect(result.assetPath).toBe('projects/p1/assets/uuid-1.png')
+ })
+
+ it('fails when a source image cannot be loaded', async () => {
+ fetch.mockResolvedValue({ ok: false })
+
+ await expect(
+ editImageNode({
+ projectId: 'p1',
+ prompt: 'make it blue',
+ sourceAssetPaths: ['projects/p1/assets/a.png'],
+ })
+ ).rejects.toThrow('Failed to load a source image')
+
+ expect(editImage).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('saveProject', () => {
+ const nodes = [
+ {
+ id: 'n1',
+ type: 'image',
+ position: { x: 0, y: 0 },
+ data: {
+ prompt: 'a cat',
+ model: 'gpt-image-2',
+ size: 'auto',
+ assetPath: null,
+ status: 'empty',
+ },
+ },
+ ]
+
+ it('writes the graph onto the stored project', async () => {
+ readProject.mockResolvedValue({
+ id: 'p1',
+ name: 'Project',
+ nodes: [],
+ edges: [],
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ })
+
+ const result = await saveProject({ projectId: 'p1', nodes, edges: [] })
+
+ expect(writeProject).toHaveBeenCalledWith(
+ client,
+ 'space-1',
+ expect.objectContaining({ id: 'p1', name: 'Project', nodes, edges: [] })
+ )
+ expect(writeProject.mock.calls[0][2].updatedAt).not.toBe(
+ '2026-01-01T00:00:00.000Z'
+ )
+ expect(result).toEqual({ id: 'p1' })
+ })
+
+ it('fails when the project does not exist', async () => {
+ readProject.mockResolvedValue(null)
+
+ await expect(
+ saveProject({ projectId: 'missing', nodes, edges: [] })
+ ).rejects.toThrow('Project not found')
+
+ expect(writeProject).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('createAssetUpload', () => {
+ it('mints an upload request with the extension of the mime type', async () => {
+ const result = await createAssetUpload({
+ projectId: 'p1',
+ file: { type: 'image/webp', size: 42 },
+ })
+
+ expect(client.space.storage.upload).toHaveBeenCalledWith(
+ 'space-1',
+ 'projects/p1/assets/uuid-1.webp',
+ { file: { type: 'image/webp', size: 42 } }
+ )
+ expect(result).toEqual({
+ path: 'projects/p1/assets/uuid-1.png',
+ uploadRequest: { method: 'PUT', url: 'https://put', headers: {} },
+ })
+ })
+ })
+
+ describe('getAssetUrls', () => {
+ it('signs the requested paths', async () => {
+ buildAssetUrls.mockResolvedValue({ 'a.png': 'https://get/a.png' })
+
+ const result = await getAssetUrls({ projectId: 'p1', paths: ['a.png'] })
+
+ expect(buildAssetUrls).toHaveBeenCalledWith(client, 'space-1', ['a.png'])
+ expect(result).toEqual({ assetUrls: { 'a.png': 'https://get/a.png' } })
+ })
+ })
+})
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts
new file mode 100644
index 0000000..27df6e4
--- /dev/null
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/assets/route.ts
@@ -0,0 +1,7 @@
+import { getAssetUrls } from '../../api'
+
+export const POST = getAssetUrls
+
+export const maxDuration = 800
+
+export const dynamic = 'force-dynamic'
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts
new file mode 100644
index 0000000..0dd5f66
--- /dev/null
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/edit/route.ts
@@ -0,0 +1,7 @@
+import { editImageNode } from '../../api'
+
+export const POST = editImageNode
+
+export const maxDuration = 800
+
+export const dynamic = 'force-dynamic'
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts
new file mode 100644
index 0000000..4e0e8cc
--- /dev/null
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/generate/route.ts
@@ -0,0 +1,7 @@
+import { generateImage } from '../../api'
+
+export const POST = generateImage
+
+export const maxDuration = 800
+
+export const dynamic = 'force-dynamic'
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts
new file mode 100644
index 0000000..c327af5
--- /dev/null
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/save/route.ts
@@ -0,0 +1,7 @@
+import { saveProject } from '../../api'
+
+export const POST = saveProject
+
+export const maxDuration = 800
+
+export const dynamic = 'force-dynamic'
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts b/platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts
new file mode 100644
index 0000000..ed7d519
--- /dev/null
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/api/upload/route.ts
@@ -0,0 +1,7 @@
+import { createAssetUpload } from '../../api'
+
+export const POST = createAssetUpload
+
+export const maxDuration = 800
+
+export const dynamic = 'force-dynamic'
diff --git a/platform/app/apps/(adhoc)/f2a9c7d4/space.ts b/platform/app/apps/(adhoc)/f2a9c7d4/space.ts
index acad029..24821a3 100644
--- a/platform/app/apps/(adhoc)/f2a9c7d4/space.ts
+++ b/platform/app/apps/(adhoc)/f2a9c7d4/space.ts
@@ -145,3 +145,31 @@ export async function listProjectIds(
return []
}
}
+
+/**
+ * Builds presigned download URLs for the given asset paths. Returns a map keyed
+ * by asset path so the client can render images without exposing storage.
+ */
+export async function buildAssetUrls(
+ client: Client,
+ spaceId: string,
+ paths: string[]
+): Promise> {
+ const entries = await Promise.all(
+ [...new Set(paths)].map(async (path) => {
+ try {
+ const { url } = await client.space.storage.download(spaceId, path)
+
+ return [path, url] as const
+ } catch {
+ return null
+ }
+ })
+ )
+
+ return Object.fromEntries(
+ entries.filter(
+ (entry): entry is readonly [string, string] => entry !== null
+ )
+ )
+}
diff --git a/platform/components/DynamicIcon.jsx b/platform/components/DynamicIcon.jsx
index 660ce8c..e641c99 100644
--- a/platform/components/DynamicIcon.jsx
+++ b/platform/components/DynamicIcon.jsx
@@ -9,6 +9,7 @@ import { tryHash } from '@/lib/url'
import Emoji from '@/components/Emoji'
import GravatarIcon from '@/components/GravatarIcon'
+import useHydrated from '@/hooks/useHydrated'
import useTheme from '@/hooks/useTheme'
import clsx from 'clsx'
@@ -200,6 +201,13 @@ export default function DynamicIcon({
}) {
const { theme } = useTheme()
+ // @note the server never knows the theme and renders the light variant; the
+ // client must render the same markup on its first pass or the dark variant's
+ // filter style is reported as a hydration mismatch
+ const hydrated = useHydrated()
+
+ const effectiveTheme = hydrated ? theme : undefined
+
const [fallbackComponent, setFallbackComponent] = useState(null)
let [icon, hash] = useMemo(() => {
@@ -217,7 +225,7 @@ export default function DynamicIcon({
dark = dark.split('#')[0].trim()
}
- if (theme === 'dark') {
+ if (effectiveTheme === 'dark') {
return [dark, darkHash?.slice(1) || '']
} else {
return [light, lightHash?.slice(1) || '']
@@ -225,7 +233,7 @@ export default function DynamicIcon({
} else {
return [_icon, '']
}
- }, [_icon, theme])
+ }, [_icon, effectiveTheme])
const className = useMemo(() => {
return clsx('dynamic-icon', _className)
diff --git a/platform/components/DynamicIcon.utest.jsx b/platform/components/DynamicIcon.utest.jsx
index 3ff4576..379f47f 100644
--- a/platform/components/DynamicIcon.utest.jsx
+++ b/platform/components/DynamicIcon.utest.jsx
@@ -1,4 +1,20 @@
-import { dynamicIconToUrl } from './DynamicIcon'
+import useHydrated from '@/hooks/useHydrated'
+import useTheme from '@/hooks/useTheme'
+
+import DynamicIcon, { dynamicIconToUrl } from './DynamicIcon'
+
+import '@testing-library/jest-dom'
+import { render } from '@testing-library/react'
+
+jest.mock('@/hooks/useHydrated', () => ({
+ __esModule: true,
+ default: jest.fn(() => true),
+}))
+
+jest.mock('@/hooks/useTheme', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({ theme: 'light' })),
+}))
describe('dynamicIconToUrl', () => {
it('returns direct URLs unchanged', () => {
@@ -35,3 +51,42 @@ describe('dynamicIconToUrl', () => {
)
})
})
+
+describe('DynamicIcon theme variants', () => {
+ const icon = '/icon.png;/icon.png#filter=invertGrayscale'
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders the light variant on the first client pass even in dark mode', () => {
+ useTheme.mockReturnValue({ theme: 'dark' })
+ useHydrated.mockReturnValue(false)
+
+ const { getByRole } = render()
+
+ expect(getByRole('img')).not.toHaveStyle({
+ filter: 'invert(1) grayscale(1)',
+ })
+ })
+
+ it('renders the dark variant once hydrated', () => {
+ useTheme.mockReturnValue({ theme: 'dark' })
+ useHydrated.mockReturnValue(true)
+
+ const { getByRole } = render()
+
+ expect(getByRole('img')).toHaveStyle({ filter: 'invert(1) grayscale(1)' })
+ })
+
+ it('keeps the light variant when hydrated in light mode', () => {
+ useTheme.mockReturnValue({ theme: 'light' })
+ useHydrated.mockReturnValue(true)
+
+ const { getByRole } = render()
+
+ expect(getByRole('img')).not.toHaveStyle({
+ filter: 'invert(1) grayscale(1)',
+ })
+ })
+})
diff --git a/platform/components/Link.jsx b/platform/components/Link.jsx
index 8a4ecd1..25c6668 100644
--- a/platform/components/Link.jsx
+++ b/platform/components/Link.jsx
@@ -59,9 +59,12 @@ export default forwardRef(function Link(
return target
}, [target])
+ // @note the App Router build of next/link rejects the locale prop, and the
+ // single configured locale needs no fallback - forward it only when a caller
+ // sets it explicitly
const resolvedLocale = useMemo(() => {
- return locale || router.locale || router.defaultLocale
- }, [locale, router.locale, router.defaultLocale])
+ return locale || undefined
+ }, [locale])
const extraProps = useMemo(() => {
switch (true) {
@@ -185,7 +188,7 @@ export default forwardRef(function Link(
target: resolvedTarget,
- locale: resolvedLocale,
+ ...(resolvedLocale ? { locale: resolvedLocale } : {}),
...props,
...extraProps,
diff --git a/platform/components/Link.utest.jsx b/platform/components/Link.utest.jsx
index ca31ce5..c6aebd2 100644
--- a/platform/components/Link.utest.jsx
+++ b/platform/components/Link.utest.jsx
@@ -98,6 +98,31 @@ describe('Link', () => {
expect(link).toHaveAttribute('tabindex', '-1')
})
+ it('does not forward a locale unless one is given', () => {
+ render(
+
+ Apps
+
+ )
+
+ expect(screen.getByRole('link', { name: 'Apps' })).not.toHaveAttribute(
+ 'locale'
+ )
+ })
+
+ it('forwards an explicit locale', () => {
+ render(
+
+ Apps
+
+ )
+
+ expect(screen.getByRole('link', { name: 'Apps' })).toHaveAttribute(
+ 'locale',
+ 'fr'
+ )
+ })
+
it('forces prefetch immediately and on interval', () => {
render(
diff --git a/platform/config/models.ts b/platform/config/models.ts
index 91ed55f..da91448 100644
--- a/platform/config/models.ts
+++ b/platform/config/models.ts
@@ -4150,11 +4150,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: Math.floor(262_144 * MAX_OUTPUT_TOKENS_RATIO),
pricing: {
- tokenRatio: 0.0222,
- inputTokenRatio: 0.01,
- outputTokenRatio: 0.0222,
- inputPrice: 0.14,
- outputPrice: 0.4,
+ tokenRatio: 0.0828,
+ inputTokenRatio: 0.0707,
+ outputTokenRatio: 0.0828,
+ inputPrice: 0.99,
+ outputPrice: 1.49,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
@@ -4334,11 +4334,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: Math.ceil(1_000_000 * MAX_OUTPUT_TOKENS_RATIO),
pricing: {
- tokenRatio: 0.11,
- inputTokenRatio: 0.0471,
- outputTokenRatio: 0.11,
- inputPrice: 0.66,
- outputPrice: 1.98,
+ tokenRatio: 0.2444,
+ inputTokenRatio: 0.15,
+ outputTokenRatio: 0.2444,
+ inputPrice: 2.1,
+ outputPrice: 4.4,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
@@ -4387,9 +4387,9 @@ export const vercelLanguageModels: Record<
// @note the gateway bills the peak rate, not DeepSeek's off-peak list price
pricing: {
tokenRatio: 0.0667,
- inputTokenRatio: 0.0214,
+ inputTokenRatio: 0.0643,
outputTokenRatio: 0.0667,
- inputPrice: 0.3,
+ inputPrice: 0.9,
outputPrice: 1.2,
},
@@ -4437,11 +4437,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: Math.ceil(1_000_000 * MAX_OUTPUT_TOKENS_RATIO),
pricing: {
- tokenRatio: 0.0144,
- inputTokenRatio: 0.0093,
- outputTokenRatio: 0.0144,
- inputPrice: 0.13,
- outputPrice: 0.26,
+ tokenRatio: 0.0733,
+ inputTokenRatio: 0.0314,
+ outputTokenRatio: 0.0733,
+ inputPrice: 0.44,
+ outputPrice: 1.32,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
@@ -4736,11 +4736,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: 131_000,
pricing: {
- tokenRatio: 0.0278,
- inputTokenRatio: 0.0107,
- outputTokenRatio: 0.0278,
- inputPrice: 0.15,
- outputPrice: 0.5,
+ tokenRatio: 0.0833,
+ inputTokenRatio: 0.0321,
+ outputTokenRatio: 0.0833,
+ inputPrice: 0.45,
+ outputPrice: 1.5,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
@@ -4833,11 +4833,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: 128_000,
pricing: {
- tokenRatio: 0.1417,
- inputTokenRatio: 0.0571,
- outputTokenRatio: 0.1417,
- inputPrice: 0.8,
- outputPrice: 2.55,
+ tokenRatio: 0.2444,
+ inputTokenRatio: 0.1,
+ outputTokenRatio: 0.2444,
+ inputPrice: 1.4,
+ outputPrice: 4.4,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
@@ -5185,11 +5185,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: 128_000,
pricing: {
- tokenRatio: 0.0667,
- inputTokenRatio: 0.0214,
- outputTokenRatio: 0.0667,
- inputPrice: 0.3,
- outputPrice: 1.2,
+ tokenRatio: 0.1333,
+ inputTokenRatio: 0.0429,
+ outputTokenRatio: 0.1333,
+ inputPrice: 0.6,
+ outputPrice: 2.4,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
@@ -5563,11 +5563,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: 131_000,
pricing: {
- tokenRatio: 0.0483,
- inputTokenRatio: 0.0311,
- outputTokenRatio: 0.0483,
- inputPrice: 0.435,
- outputPrice: 0.87,
+ tokenRatio: 0.1667,
+ inputTokenRatio: 0.0714,
+ outputTokenRatio: 0.1667,
+ inputPrice: 1,
+ outputPrice: 3,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
@@ -5606,11 +5606,11 @@ export const vercelLanguageModels: Record<
maxOutputTokens: 131_000,
pricing: {
- tokenRatio: 0.0156,
- inputTokenRatio: 0.01,
- outputTokenRatio: 0.0156,
- inputPrice: 0.14,
- outputPrice: 0.28,
+ tokenRatio: 0.1111,
+ inputTokenRatio: 0.0286,
+ outputTokenRatio: 0.1111,
+ inputPrice: 0.4,
+ outputPrice: 2,
},
interactionMaxMessages: DEFAULT_INTERACTION_MAX_MESSAGES,
diff --git a/platform/lib/app.route.ts b/platform/lib/app.route.ts
index ab6347e..e0f76d3 100644
--- a/platform/lib/app.route.ts
+++ b/platform/lib/app.route.ts
@@ -1,5 +1,8 @@
import '@/lib/scope.server'
+import type { UnwrapPromise } from '@chatbotkit-dev/typescript-utils/promise'
+
+import { ensureContact } from '@/lib/app.contact'
import type { StoreConfig, StoreSession } from '@/lib/app.context'
import {
getContextAppConfig,
@@ -12,32 +15,35 @@ import {
getContextRequestHost,
runInContext,
} from '@/lib/context.store'
-import type { Stream, StreamEvent } from '@/lib/stream'
-import { withStream } from '@/lib/stream'
import { captureException } from '@/lib/error'
import { withAny } from '@/lib/method'
import { parseRequestSchema } from '@/lib/request'
import { throwNotAuthenticated, throwNotAuthorized } from '@/lib/response'
+import type { Stream, StreamEvent } from '@/lib/stream'
+import { withStream } from '@/lib/stream'
import type { ZodSchema } from 'zod'
-interface Context {
+export interface AppRouteContext {
host?: string
}
/**
* This is a helper function that creates an route handler for an app with
* session handling and input validation built-in.
+ *
+ * @note a resolved value is sent as the stream result while an (async)
+ * iterable is pushed event by event, so `R` is only constrained for the latter
*/
-export function appRouteHandler(
+export function appRouteHandler(
app: string,
schema: ZodSchema,
fn: (
config: StoreConfig,
session: StoreSession,
input: T,
- context: Context
- ) => Promise | AsyncGenerator
+ context: AppRouteContext
+ ) => Promise | AsyncGenerator
): (req: Request) => Promise {
return withAny(
withStream(async (req, stream) => {
@@ -92,7 +98,9 @@ export function appRouteHandler(
it !== null &&
(Symbol.asyncIterator in it || Symbol.iterator in it)
) {
- for await (const item of it as AsyncIterable | Iterable) {
+ for await (const item of it as
+ | AsyncIterable
+ | Iterable) {
await stream.push(item)
}
} else {
@@ -112,3 +120,38 @@ export function appRouteHandler(
})
) as (req: Request) => Promise
}
+
+/**
+ * This is a helper function that creates a route handler for an app with
+ * session handling, contact ensuring, config validation and input validation
+ * built-in. It is the route handler counterpart of `appContactActionHandler`.
+ */
+export function appContactRouteHandler(
+ app: string,
+ namespace: string,
+ configSchema: ZodSchema,
+ inputSchema: ZodSchema,
+ fn: (
+ config: U,
+ session: StoreSession,
+ contact: UnwrapPromise>,
+ input: T,
+ context: AppRouteContext
+ ) => Promise | AsyncGenerator
+): (req: Request) => Promise {
+ // @note the async wrapper resolves to either the result or the generator,
+ // and the route handler tells them apart after awaiting
+
+ return appRouteHandler>(
+ app,
+ inputSchema,
+ async (config, session, input, context) => {
+ const [parsedConfig, contact] = await Promise.all([
+ configSchema.parseAsync(config),
+ ensureContact({ namespace: namespace, session: session, app: app }),
+ ])
+
+ return fn(parsedConfig, session, contact, input, context)
+ }
+ )
+}
diff --git a/platform/lib/app.route.utest.js b/platform/lib/app.route.utest.js
index 9102c70..7cb595d 100644
--- a/platform/lib/app.route.utest.js
+++ b/platform/lib/app.route.utest.js
@@ -1,3 +1,4 @@
+import { ensureContact } from '@/lib/app.contact'
import { getContextAppConfig, getContextAppSession } from '@/lib/app.context'
import { APP_AUDIENCE } from '@/lib/audience.consts'
import {
@@ -8,10 +9,14 @@ import { captureException } from '@/lib/error'
import { parseRequestSchema } from '@/lib/request'
import { throwNotAuthenticated, throwNotAuthorized } from '@/lib/response'
-import { appRouteHandler } from './app.route'
+import { appContactRouteHandler, appRouteHandler } from './app.route'
import { z } from 'zod'
+jest.mock('@/lib/app.contact', () => ({
+ ensureContact: jest.fn(),
+}))
+
jest.mock('@/lib/app.context', () => ({
getContextAppConfig: jest.fn(),
getContextAppSession: jest.fn(),
@@ -262,3 +267,106 @@ describe('appRouteHandler', () => {
expect(mockStream.result).not.toHaveBeenCalled()
})
})
+
+describe('appContactRouteHandler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ getContextFrontendHost.mockReturnValue(undefined)
+ getContextRequestHost.mockReturnValue(undefined)
+ })
+
+ it('should ensure the contact and pass parsed config, contact and input', async () => {
+ const configSchema = z.object({ flag: z.boolean().default(false) })
+ const inputSchema = z.object({ name: z.string() })
+ const fn = jest.fn().mockResolvedValue({ ok: true })
+
+ const mockSession = { payload: { aud: 'other' } }
+ const mockContact = { id: 'contact-1' }
+
+ getContextAppConfig.mockResolvedValue({})
+ getContextAppSession.mockResolvedValue(mockSession)
+ parseRequestSchema.mockResolvedValue({ name: 'test' })
+ ensureContact.mockResolvedValue(mockContact)
+
+ const handler = appContactRouteHandler(
+ 'test-app',
+ 'test-namespace',
+ configSchema,
+ inputSchema,
+ fn
+ )
+ const mockReq = new Request('http://localhost/test')
+ const mockStream = { push: jest.fn(), result: jest.fn(), error: jest.fn() }
+
+ await handler(mockReq, mockStream)
+
+ expect(ensureContact).toHaveBeenCalledWith({
+ namespace: 'test-namespace',
+ session: mockSession,
+ app: 'test-app',
+ })
+ expect(fn).toHaveBeenCalledWith(
+ { flag: false },
+ mockSession,
+ mockContact,
+ { name: 'test' },
+ expect.any(Object)
+ )
+ expect(mockStream.result).toHaveBeenCalledWith({ ok: true })
+ })
+
+ it('should stream generator events from the contact handler', async () => {
+ const fn = jest.fn().mockImplementation(async function* () {
+ yield { type: 'item', data: { n: 1 } }
+ })
+
+ getContextAppConfig.mockResolvedValue({})
+ getContextAppSession.mockResolvedValue({ payload: { aud: 'other' } })
+ parseRequestSchema.mockResolvedValue({})
+ ensureContact.mockResolvedValue({ id: 'contact-1' })
+
+ const handler = appContactRouteHandler(
+ 'test-app',
+ 'test-namespace',
+ z.object({}),
+ z.object({}),
+ fn
+ )
+ const mockReq = new Request('http://localhost/test')
+ const mockStream = { push: jest.fn(), result: jest.fn(), error: jest.fn() }
+
+ await handler(mockReq, mockStream)
+
+ expect(mockStream.push).toHaveBeenCalledWith({
+ type: 'item',
+ data: { n: 1 },
+ })
+ expect(mockStream.result).not.toHaveBeenCalled()
+ })
+
+ it('should report a contact failure through the stream error', async () => {
+ const fn = jest.fn()
+ const contactError = new Error('Contact not found')
+
+ getContextAppConfig.mockResolvedValue({})
+ getContextAppSession.mockResolvedValue({ payload: { aud: 'other' } })
+ parseRequestSchema.mockResolvedValue({})
+ ensureContact.mockRejectedValue(contactError)
+
+ const handler = appContactRouteHandler(
+ 'test-app',
+ 'test-namespace',
+ z.object({}),
+ z.object({}),
+ fn
+ )
+ const mockReq = new Request('http://localhost/test')
+ const mockStream = { push: jest.fn(), result: jest.fn(), error: jest.fn() }
+
+ await handler(mockReq, mockStream)
+
+ expect(fn).not.toHaveBeenCalled()
+ expect(captureException).toHaveBeenCalledWith(contactError)
+ expect(mockStream.error).toHaveBeenCalledWith(contactError)
+ })
+})
diff --git a/platform/lib/auth.adapter.ts b/platform/lib/auth.adapter.ts
index acb63d8..0543b8b 100644
--- a/platform/lib/auth.adapter.ts
+++ b/platform/lib/auth.adapter.ts
@@ -27,10 +27,21 @@ export const adapter: Required = ((adapter) => {
// the WHERE clause and the SET clause, causing Prisma to emit an
// inefficient `id IN () AND (? = ?)` pattern on MariaDB
- return prisma.session.update({
- where: { sessionToken },
- data: rest,
- })
+ // @note this only bumps the session expiry. NextAuth treats a
+ // throw here as a lost session and bounces the user to sign-in, so
+ // a failed bump is reported and ignored: the session was already
+ // read with a valid expiry and the next request bumps it again.
+
+ try {
+ return await prisma.session.update({
+ where: { sessionToken },
+ data: rest,
+ })
+ } catch (e) {
+ await captureException(e)
+
+ return null
+ }
}
}
diff --git a/platform/lib/auth.adapter.utest.js b/platform/lib/auth.adapter.utest.js
index e07c528..9284a91 100644
--- a/platform/lib/auth.adapter.utest.js
+++ b/platform/lib/auth.adapter.utest.js
@@ -21,6 +21,9 @@ jest.mock('@/prisma/client', () => ({
verificationToken: {
deleteMany: jest.fn(),
},
+ session: {
+ update: jest.fn(),
+ },
},
}))
@@ -76,6 +79,41 @@ describe('auth.adapter', () => {
})
})
+ describe('updateSession', () => {
+ it('should update the session without the token in the data payload', async () => {
+ const expires = new Date('2026-10-01T00:00:00.000Z')
+
+ prisma.session.update.mockResolvedValue({ sessionToken: 'tok', expires })
+
+ const result = await adapter.updateSession({
+ sessionToken: 'tok',
+ expires,
+ })
+
+ expect(prisma.session.update).toHaveBeenCalledWith({
+ where: { sessionToken: 'tok' },
+ data: { expires },
+ })
+ expect(result).toEqual({ sessionToken: 'tok', expires })
+ })
+
+ it('should report and swallow a failed expiry bump', async () => {
+ const error = new Error(
+ 'target: main.-.primary: vttablet: rpc error: code = Aborted desc = transaction ended (transaction committed)'
+ )
+
+ prisma.session.update.mockRejectedValue(error)
+
+ const result = await adapter.updateSession({
+ sessionToken: 'tok',
+ expires: new Date(),
+ })
+
+ expect(result).toBeNull()
+ expect(captureException).toHaveBeenCalledWith(error)
+ })
+ })
+
describe('createUser', () => {
it('should create user and trigger setup event', async () => {
const userData = {
diff --git a/platform/lib/model.provider.vercel.utest.js b/platform/lib/model.provider.vercel.utest.js
index bfadf72..e47b1a5 100644
--- a/platform/lib/model.provider.vercel.utest.js
+++ b/platform/lib/model.provider.vercel.utest.js
@@ -325,6 +325,85 @@ describeIfConfigured('listModels', () => {
}
}
+ // @note the catalogue's single `pricing` block and context figures describe
+ // whichever backend the gateway chose to list, and change as backends come
+ // and go. A request is billed at the rate of the backend the gateway routes
+ // it to, so the configuration is held to the most expensive backend the
+ // model can reach (within its `gateway.only` pin): no routing decision may
+ // bill more than the configuration charges. Long-context tiers and
+ // peak-hour multipliers are left out, as they are for the catalogue price.
+ // Sizing is held to the backends the same way: the configured context must
+ // be one a reachable backend actually offers.
+ async function getEndpointFacts(modelId, config) {
+ const response = await fetch(
+ `https://ai-gateway.vercel.sh/v1/models/${modelId}/endpoints`,
+ {
+ headers: {
+ Authorization: `Bearer ${getVercelAPIKey()}`,
+ 'Content-Type': 'application/json',
+ },
+ }
+ )
+
+ if (!response.ok) {
+ return undefined
+ }
+
+ const { data } = await response.json()
+
+ const only = config.providerOptions?.gateway?.only
+
+ // endpoint slugs can be longer than the pin slug (`vertexAnthropic` for
+ // a `vertex` pin), so match on the prefix
+ const endpoints = (data?.endpoints || []).filter(
+ (endpoint) =>
+ !only?.length ||
+ only.some((slug) =>
+ String(endpoint.provider_name)
+ .toLowerCase()
+ .startsWith(slug.toLowerCase())
+ )
+ )
+
+ const rated = endpoints.map((endpoint) => ({
+ provider: endpoint.provider_name,
+ input: Number(endpoint.pricing?.prompt || 0),
+ output: Number(endpoint.pricing?.completion || 0),
+ }))
+
+ const input = Math.max(0, ...rated.map((item) => item.input))
+ const output = Math.max(0, ...rated.map((item) => item.output))
+
+ const providersAt = (side, value) =>
+ rated
+ .filter((item) => item[side] === value)
+ .map((item) => item.provider)
+ .join('|')
+
+ const contextLengths = endpoints
+ .map((endpoint) => endpoint.context_length)
+ .filter((value) => typeof value === 'number')
+ const maxCompletionTokens = endpoints
+ .map((endpoint) => endpoint.max_completion_tokens)
+ .filter((value) => typeof value === 'number')
+
+ return {
+ pricing:
+ input || output
+ ? {
+ input,
+ output,
+ inputProvider: providersAt('input', input),
+ outputProvider: providersAt('output', output),
+ }
+ : undefined,
+ contextLengths,
+ maxCompletionTokens: maxCompletionTokens.length
+ ? Math.max(...maxCompletionTokens)
+ : undefined,
+ }
+ }
+
it('must match configured Vercel model pricing and sizing', async () => {
const response = await fetch('https://ai-gateway.vercel.sh/v1/models', {
headers: {
@@ -339,18 +418,28 @@ describeIfConfigured('listModels', () => {
const modelsById = new Map(data.map((model) => [model.id, model]))
+ const languageModels = Object.entries(vercelLanguageModels).filter(
+ ([, config]) => !config.deprecated && config.visible
+ )
+
+ const endpointFactsByModel = new Map(
+ await Promise.all(
+ languageModels.map(async ([modelName, config]) => [
+ modelName,
+ await getEndpointFacts(getLanguageModel({ model: modelName }), config),
+ ])
+ )
+ )
+
const mismatches = []
// Models the gateway lists but for which it publishes no token pricing
// (e.g. the perplexity/sonar* family return an empty `pricing` object).
// Their absolute price cannot be diffed against upstream; we still check
// ratio consistency and surface them so the gap is visible, not silent.
const unverified = []
+ const overpriced = []
- for (const [modelName, config] of Object.entries(vercelLanguageModels)) {
- if (config.deprecated || !config.visible) {
- continue
- }
-
+ for (const [modelName, config] of languageModels) {
const modelId = getLanguageModel({ model: modelName })
const liveModel = modelsById.get(modelId)
@@ -360,13 +449,29 @@ describeIfConfigured('listModels', () => {
continue
}
- const liveInputPrice = parseVercelPrice(liveModel.pricing?.input)
- const liveOutputPrice = parseVercelPrice(liveModel.pricing?.output)
- const liveContextLength = liveModel.context_window
- const liveMaxCompletionTokens = liveModel.max_tokens
+ const endpointFacts = endpointFactsByModel.get(modelName)
+ const worstCase = endpointFacts?.pricing
+
+ const liveInputPrice = parseVercelPrice(
+ worstCase ? worstCase.input : liveModel.pricing?.input
+ )
+ const liveOutputPrice = parseVercelPrice(
+ worstCase ? worstCase.output : liveModel.pricing?.output
+ )
+ const liveContextLengths = endpointFacts?.contextLengths.length
+ ? endpointFacts.contextLengths
+ : [liveModel.context_window].filter((value) => typeof value === 'number')
+ // an upper bound, so the most permissive figure either source reports
+ const liveMaxCompletionTokens = Math.max(
+ ...[endpointFacts?.maxCompletionTokens, liveModel.max_tokens].filter(
+ (value) => typeof value === 'number'
+ )
+ )
const hasUpstreamPricing =
- liveModel.pricing?.input != null || liveModel.pricing?.output != null
+ Boolean(worstCase) ||
+ liveModel.pricing?.input != null ||
+ liveModel.pricing?.output != null
if (!hasUpstreamPricing) {
// The gateway lists the model but publishes no token pricing - there is
@@ -380,30 +485,45 @@ describeIfConfigured('listModels', () => {
`${modelName}: upstream reports zero pricing (input=${liveInputPrice}, output=${liveOutputPrice})`
)
} else {
- if (roundPrice(config.pricing.inputPrice || 0) !== liveInputPrice) {
- mismatches.push(
- `${modelName}: inputPrice ${config.pricing.inputPrice} !== ${liveInputPrice}`
- )
- }
-
- if (roundPrice(config.pricing.outputPrice || 0) !== liveOutputPrice) {
- mismatches.push(
- `${modelName}: outputPrice ${config.pricing.outputPrice} !== ${liveOutputPrice}`
- )
+ // @note backends come and go, so the ceiling moves in both
+ // directions. Charging less than the ceiling under-bills and fails;
+ // charging more is margin left behind by a backend that went away,
+ // worth a look but not a defect.
+ const sides = [
+ ['inputPrice', liveInputPrice, worstCase?.inputProvider],
+ ['outputPrice', liveOutputPrice, worstCase?.outputProvider],
+ ]
+
+ for (const [side, livePrice, provider] of sides) {
+ const configuredPrice = roundPrice(config.pricing[side] || 0)
+ const detail =
+ `${modelName}: ${side} ${config.pricing[side]} ` +
+ `${configuredPrice < livePrice ? '<' : '>'} ${livePrice}` +
+ (provider ? ` (${provider})` : '')
+
+ if (configuredPrice < livePrice) {
+ mismatches.push(detail)
+ } else if (configuredPrice > livePrice) {
+ overpriced.push(detail)
+ }
}
}
if (
- typeof liveContextLength === 'number' &&
- config.maxTokens !== liveContextLength
+ liveContextLengths.length &&
+ !liveContextLengths.includes(config.maxTokens)
) {
mismatches.push(
- `${modelName}: maxTokens ${config.maxTokens} !== ${liveContextLength}`
+ `${modelName}: maxTokens ${config.maxTokens} not in ${[
+ ...new Set(liveContextLengths),
+ ]
+ .sort((a, b) => a - b)
+ .join('|')}`
)
}
if (
- typeof liveMaxCompletionTokens === 'number' &&
+ Number.isFinite(liveMaxCompletionTokens) &&
config.maxOutputTokens > liveMaxCompletionTokens
) {
mismatches.push(
@@ -412,12 +532,12 @@ describeIfConfigured('listModels', () => {
}
if (
- typeof liveContextLength === 'number' &&
- config.maxInputTokens > liveContextLength - config.maxOutputTokens
+ liveContextLengths.length &&
+ config.maxInputTokens > config.maxTokens - config.maxOutputTokens
) {
mismatches.push(
`${modelName}: maxInputTokens ${config.maxInputTokens} > ${
- liveContextLength - config.maxOutputTokens
+ config.maxTokens - config.maxOutputTokens
}`
)
}
@@ -721,6 +841,14 @@ describeIfConfigured('listModels', () => {
}
}
+ if (overpriced.length) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[vercel-catalogue] ${overpriced.length} model(s) priced above the ` +
+ `most expensive reachable backend: ${overpriced.join('; ')}`
+ )
+ }
+
if (unverified.length) {
// eslint-disable-next-line no-console
console.warn(
@@ -739,18 +867,7 @@ describeIfConfigured('listModels', () => {
// longer matches anything means upstream fixed their catalogue - the test
// then fails with a "stale exception" line so the entry is removed rather
// than lingering. To extend, re-verify upstream first, then bump `expires`.
- const upstreamMismatchExceptions = [
- {
- // MiniMax documents up to 1M context, but the gateway lists 512k.
- prefix: 'minimax-m3: maxTokens',
- expires: '2026-10-11',
- },
- {
- // 1M context minus the documented 128k maximum output leaves 872k input.
- prefix: 'minimax-m3: maxInputTokens',
- expires: '2026-10-11',
- },
- ]
+ const upstreamMismatchExceptions = []
const exceptionStates = upstreamMismatchExceptions.map((exception) => ({
exception,
diff --git a/platform/lib/session.get.js b/platform/lib/session.get.js
index c96779b..8123ad8 100644
--- a/platform/lib/session.get.js
+++ b/platform/lib/session.get.js
@@ -741,10 +741,21 @@ export async function getSession(req, res) {
res || getContextNextApiResponse()
)
- session =
- req instanceof ServerActionRequest
- ? await getServerSession(authOptions)
- : await getServerSession(incomingReq, incomingRes, authOptions)
+ // @note App Router route handlers pass a Web Request and register no
+ // NextApiRequest in context. next-auth reads `cookies` and a plain
+ // `headers` object off the request it is given, and a Web Request has
+ // neither, so it would never find the session cookie. Those requests
+ // resolve through the next/headers form, the same way server actions do.
+
+ const useHeadersStore =
+ req instanceof ServerActionRequest ||
+ (!getContextNextApiRequest() &&
+ typeof Request !== 'undefined' &&
+ req instanceof Request)
+
+ session = useHeadersStore
+ ? await getServerSession(authOptions)
+ : await getServerSession(incomingReq, incomingRes, authOptions)
}
// We check if the session is valid and if it is not we throw an error.
diff --git a/platform/lib/session.get.utest.js b/platform/lib/session.get.utest.js
index fa03177..7080c48 100644
--- a/platform/lib/session.get.utest.js
+++ b/platform/lib/session.get.utest.js
@@ -1667,6 +1667,27 @@ describe('Session resolution (next-auth session)', () => {
)
})
+ it('resolves an App Router route handler Request through next/headers', async () => {
+ // @note a Web Request without a NextApiRequest in context is what App
+ // Router route handlers pass; next-auth cannot read cookies off it
+ const req = new Request('http://localhost/apps/test/api/op', {
+ method: 'POST',
+ headers: {
+ 'x-requested-with': 'XMLHttpRequest',
+ cookie: 'next-auth.session-token=anything',
+ },
+ })
+
+ hasProtection.mockReturnValue(true)
+ getServerSession.mockResolvedValue(mockValidSession)
+
+ const session = await getSession(req)
+
+ expect(session.user.email).toBe('test@test.com')
+ expect(getServerSession).toHaveBeenCalledTimes(1)
+ expect(getServerSession).toHaveBeenCalledWith(authOptions)
+ })
+
it('never makes an outbound request to resolve a cookie session', async () => {
const fetchSpy = jest.spyOn(global, 'fetch')
diff --git a/platform/next.config.d/hmr.config.js b/platform/next.config.d/hmr.config.js
new file mode 100644
index 0000000..b978a53
--- /dev/null
+++ b/platform/next.config.d/hmr.config.js
@@ -0,0 +1,16 @@
+// @ts-check
+
+/** @type {import('next').NextConfig} */
+export default {
+ experimental: {
+ // @note dev only. The Server Components HMR cache replays every fetch a
+ // render made, keyed by URL and body, when the page refreshes after a code
+ // change - `cache: 'no-store'` does not opt a call out. The PlanetScale
+ // driver talks to the database over fetch and its `BEGIN` request has the
+ // same body every time, so a refresh handed the driver the session of a
+ // transaction that had already committed and the next statement failed
+ // with `transaction ... ended (transaction committed)`. Stateful HTTP
+ // protocols cannot be replayed, so the cache stays off.
+ serverComponentsHmrCache: false,
+ },
+}
diff --git a/platform/next.config.d/hmr.config.utest.js b/platform/next.config.d/hmr.config.utest.js
new file mode 100644
index 0000000..7105c19
--- /dev/null
+++ b/platform/next.config.d/hmr.config.utest.js
@@ -0,0 +1,9 @@
+import config from './hmr.config'
+
+describe('hmr.config', () => {
+ it('keeps the Server Components HMR fetch cache off', () => {
+ // @note the database driver speaks a stateful protocol over fetch; a
+ // replayed `BEGIN` binds a request to an already committed transaction
+ expect(config.experimental.serverComponentsHmrCache).toBe(false)
+ })
+})