fix(mcp): add shared operations and secure scene APIs
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSceneStore } from '@/lib/scene-store-server'
|
||||
import {
|
||||
guardSceneApiRequest,
|
||||
sceneApiJson,
|
||||
sceneApiPreflight,
|
||||
withSceneApiHeaders,
|
||||
} from '@/lib/scene-api-security'
|
||||
import { getSceneOperations } from '@/lib/scene-store-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@@ -10,17 +15,24 @@ const POLL_MS = 250
|
||||
const HEARTBEAT_MS = 15_000
|
||||
const MAX_EVENTS_PER_POLL = 50
|
||||
|
||||
export async function GET(request: Request, { params }: RouteParams) {
|
||||
const { id } = await params
|
||||
const store = await getSceneStore()
|
||||
export function OPTIONS(request: Request) {
|
||||
return sceneApiPreflight(request)
|
||||
}
|
||||
|
||||
if (!store.listSceneEvents) {
|
||||
return NextResponse.json({ error: 'scene_events_unavailable' }, { status: 501 })
|
||||
export async function GET(request: Request, { params }: RouteParams) {
|
||||
const guard = guardSceneApiRequest(request)
|
||||
if (guard) return guard
|
||||
|
||||
const { id } = await params
|
||||
const operations = await getSceneOperations()
|
||||
|
||||
if (!operations.canListSceneEvents) {
|
||||
return sceneApiJson(request, { error: 'scene_events_unavailable' }, { status: 501 })
|
||||
}
|
||||
|
||||
const scene = await store.load(id)
|
||||
const scene = await operations.loadStoredScene(id)
|
||||
if (!scene) {
|
||||
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
||||
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
@@ -61,7 +73,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
const poll = async () => {
|
||||
if (closed) return
|
||||
try {
|
||||
const events = await store.listSceneEvents!(id, {
|
||||
const events = await operations.listSceneEvents(id, {
|
||||
afterEventId: cursor,
|
||||
limit: MAX_EVENTS_PER_POLL,
|
||||
})
|
||||
@@ -90,12 +102,15 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
return withSceneApiHeaders(
|
||||
request,
|
||||
new Response(stream, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { apiGraphSchema } from '@/lib/graph-schema'
|
||||
import { getSceneStore } from '@/lib/scene-store-server'
|
||||
import {
|
||||
guardSceneApiRequest,
|
||||
sceneApiJson,
|
||||
sceneApiPreflight,
|
||||
withSceneApiHeaders,
|
||||
} from '@/lib/scene-api-security'
|
||||
import { getSceneOperations } from '@/lib/scene-store-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -19,30 +25,41 @@ const patchSceneSchema = z.object({
|
||||
expectedVersion: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
export function OPTIONS(request: NextRequest) {
|
||||
return sceneApiPreflight(request)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const guard = guardSceneApiRequest(request)
|
||||
if (guard) return guard
|
||||
|
||||
const { id } = await params
|
||||
const store = await getSceneStore()
|
||||
const operations = await getSceneOperations()
|
||||
try {
|
||||
const scene = await store.load(id)
|
||||
const scene = await operations.loadStoredScene(id)
|
||||
if (!scene) {
|
||||
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
||||
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json(scene, {
|
||||
return sceneApiJson(request, scene, {
|
||||
headers: { ETag: `"${scene.version}"` },
|
||||
})
|
||||
} catch (error) {
|
||||
return handleStoreError(error)
|
||||
return handleStoreError(request, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
const guard = guardSceneApiRequest(request)
|
||||
if (guard) return guard
|
||||
|
||||
const { id } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
{ error: 'invalid_request', details: 'body must be valid JSON' },
|
||||
{ status: 400 },
|
||||
)
|
||||
@@ -50,7 +67,8 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const parsed = putSceneSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
{ error: 'invalid_request', details: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
)
|
||||
@@ -59,13 +77,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
|
||||
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
|
||||
|
||||
const store = await getSceneStore()
|
||||
const operations = await getSceneOperations()
|
||||
try {
|
||||
const existing = await store.load(id)
|
||||
const existing = await operations.loadStoredScene(id)
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
||||
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
|
||||
}
|
||||
const meta = await store.save({
|
||||
const meta = await operations.saveScene({
|
||||
id,
|
||||
name: parsed.data.name ?? existing.name,
|
||||
projectId: existing.projectId,
|
||||
@@ -75,38 +93,45 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl,
|
||||
expectedVersion: expectedVersion ?? existing.version,
|
||||
})
|
||||
return NextResponse.json(meta, {
|
||||
return sceneApiJson(request, meta, {
|
||||
headers: { ETag: `"${meta.version}"` },
|
||||
})
|
||||
} catch (error) {
|
||||
return handleStoreError(error, { includeCurrentVersionFor: id })
|
||||
return handleStoreError(request, error, { includeCurrentVersionFor: id })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
const guard = guardSceneApiRequest(request)
|
||||
if (guard) return guard
|
||||
|
||||
const { id } = await params
|
||||
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
|
||||
|
||||
const store = await getSceneStore()
|
||||
const operations = await getSceneOperations()
|
||||
try {
|
||||
const removed = await store.delete(id, { expectedVersion: ifMatch })
|
||||
const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch })
|
||||
if (!removed) {
|
||||
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
||||
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
|
||||
}
|
||||
return new NextResponse(null, { status: 204 })
|
||||
return withSceneApiHeaders(request, new NextResponse(null, { status: 204 }))
|
||||
} catch (error) {
|
||||
return handleStoreError(error, { includeCurrentVersionFor: id })
|
||||
return handleStoreError(request, error, { includeCurrentVersionFor: id })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const guard = guardSceneApiRequest(request)
|
||||
if (guard) return guard
|
||||
|
||||
const { id } = await params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
{ error: 'invalid_request', details: 'body must be valid JSON' },
|
||||
{ status: 400 },
|
||||
)
|
||||
@@ -114,7 +139,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const parsed = patchSceneSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
{ error: 'invalid_request', details: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
)
|
||||
@@ -123,14 +149,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
|
||||
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
|
||||
|
||||
const store = await getSceneStore()
|
||||
const operations = await getSceneOperations()
|
||||
try {
|
||||
const meta = await store.rename(id, parsed.data.name, { expectedVersion })
|
||||
return NextResponse.json(meta, {
|
||||
const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion })
|
||||
return sceneApiJson(request, meta, {
|
||||
headers: { ETag: `"${meta.version}"` },
|
||||
})
|
||||
} catch (error) {
|
||||
return handleStoreError(error, { includeCurrentVersionFor: id })
|
||||
return handleStoreError(request, error, { includeCurrentVersionFor: id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +178,7 @@ function parseIfMatch(raw: string | null): number | undefined {
|
||||
}
|
||||
|
||||
async function handleStoreError(
|
||||
request: NextRequest,
|
||||
error: unknown,
|
||||
opts: { includeCurrentVersionFor?: string } = {},
|
||||
): Promise<NextResponse> {
|
||||
@@ -160,14 +187,15 @@ async function handleStoreError(
|
||||
let currentVersion: number | undefined
|
||||
if (opts.includeCurrentVersionFor) {
|
||||
try {
|
||||
const store = await getSceneStore()
|
||||
const current = await store.load(opts.includeCurrentVersionFor)
|
||||
const operations = await getSceneOperations()
|
||||
const current = await operations.loadStoredScene(opts.includeCurrentVersionFor)
|
||||
currentVersion = current?.version
|
||||
} catch {
|
||||
// Best-effort; skip reporting currentVersion on secondary failure.
|
||||
}
|
||||
}
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
currentVersion === undefined
|
||||
? { error: 'version_conflict' }
|
||||
: { error: 'version_conflict', currentVersion },
|
||||
@@ -175,14 +203,14 @@ async function handleStoreError(
|
||||
)
|
||||
}
|
||||
if (code === 'not_found') {
|
||||
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
||||
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
|
||||
}
|
||||
if (code === 'too_large') {
|
||||
return NextResponse.json({ error: 'too_large' }, { status: 413 })
|
||||
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
|
||||
}
|
||||
if (code === 'invalid') {
|
||||
return NextResponse.json({ error: 'invalid' }, { status: 400 })
|
||||
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'unexpected_error'
|
||||
return NextResponse.json({ error: 'internal_error', message }, { status: 500 })
|
||||
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { apiGraphSchema } from '@/lib/graph-schema'
|
||||
import { getSceneStore } from '@/lib/scene-store-server'
|
||||
import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security'
|
||||
import { getSceneOperations } from '@/lib/scene-store-server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,33 +19,45 @@ const listQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(500).optional(),
|
||||
})
|
||||
|
||||
export function OPTIONS(request: NextRequest) {
|
||||
return sceneApiPreflight(request)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = guardSceneApiRequest(request)
|
||||
if (guard) return guard
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = listQuerySchema.safeParse({
|
||||
projectId: url.searchParams.get('projectId') ?? undefined,
|
||||
limit: url.searchParams.get('limit') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
{ error: 'invalid_request', details: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const store = await getSceneStore()
|
||||
const scenes = await store.list({
|
||||
const operations = await getSceneOperations()
|
||||
const scenes = await operations.listScenes({
|
||||
projectId: parsed.data.projectId,
|
||||
limit: parsed.data.limit,
|
||||
})
|
||||
return NextResponse.json({ scenes })
|
||||
return sceneApiJson(request, { scenes })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const guard = guardSceneApiRequest(request)
|
||||
if (guard) return guard
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
{ error: 'invalid_request', details: 'body must be valid JSON' },
|
||||
{ status: 400 },
|
||||
)
|
||||
@@ -52,44 +65,45 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const parsed = createSceneSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
return sceneApiJson(
|
||||
request,
|
||||
{ error: 'invalid_request', details: parsed.error.issues },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const store = await getSceneStore()
|
||||
const operations = await getSceneOperations()
|
||||
try {
|
||||
const meta = await store.save({
|
||||
const meta = await operations.saveScene({
|
||||
id: parsed.data.id,
|
||||
name: parsed.data.name,
|
||||
projectId: parsed.data.projectId ?? null,
|
||||
graph: parsed.data.graph as never,
|
||||
thumbnailUrl: parsed.data.thumbnailUrl ?? null,
|
||||
})
|
||||
return NextResponse.json(meta, {
|
||||
return sceneApiJson(request, meta, {
|
||||
status: 201,
|
||||
headers: { Location: `/scene/${meta.id}` },
|
||||
})
|
||||
} catch (error) {
|
||||
return handleStoreError(error)
|
||||
return handleStoreError(request, error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleStoreError(error: unknown): NextResponse {
|
||||
function handleStoreError(request: NextRequest, error: unknown): NextResponse {
|
||||
const code = (error as { code?: string })?.code
|
||||
if (code === 'version_conflict') {
|
||||
return NextResponse.json({ error: 'version_conflict' }, { status: 409 })
|
||||
return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 })
|
||||
}
|
||||
if (code === 'not_found') {
|
||||
return NextResponse.json({ error: 'not_found' }, { status: 404 })
|
||||
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
|
||||
}
|
||||
if (code === 'too_large') {
|
||||
return NextResponse.json({ error: 'too_large' }, { status: 413 })
|
||||
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
|
||||
}
|
||||
if (code === 'invalid') {
|
||||
return NextResponse.json({ error: 'invalid' }, { status: 400 })
|
||||
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'unexpected_error'
|
||||
return NextResponse.json({ error: 'internal_error', message }, { status: 500 })
|
||||
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user