fix(mcp): add shared operations and secure scene APIs

This commit is contained in:
Aymeric Rabot
2026-04-27 17:10:25 -04:00
parent 058ee747c9
commit 8dfd1a9429
81 changed files with 1332 additions and 1786 deletions
+33 -18
View File
@@ -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',
},
}),
)
}
+61 -33
View File
@@ -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 })
}
+32 -18
View File
@@ -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 })
}
@@ -0,0 +1,64 @@
import { afterEach, expect, test } from 'bun:test'
import { guardSceneApiRequest, sceneApiPreflight } from './scene-api-security'
const OLD_ENV = { ...process.env }
afterEach(() => {
restoreEnv('PASCAL_SCENE_API_TOKEN')
restoreEnv('PASCAL_SCENE_API_ORIGINS')
restoreEnv('PASCAL_SCENE_API_RATE_LIMIT')
})
function restoreEnv(key: keyof NodeJS.ProcessEnv): void {
if (OLD_ENV[key] === undefined) delete process.env[key]
else process.env[key] = OLD_ENV[key]
}
test('allows loopback scene API requests without a token', () => {
delete process.env.PASCAL_SCENE_API_TOKEN
const request = new Request('http://127.0.0.1:3000/api/scenes', {
headers: { host: '127.0.0.1:3000' },
})
expect(guardSceneApiRequest(request)).toBeNull()
})
test('requires a token for non-loopback scene API requests', async () => {
delete process.env.PASCAL_SCENE_API_TOKEN
const request = new Request('https://editor.example/api/scenes', {
headers: { host: 'editor.example' },
})
const response = guardSceneApiRequest(request)
expect(response?.status).toBe(503)
expect(await response?.json()).toEqual({ error: 'scene_api_token_required' })
})
test('accepts bearer token auth when configured', () => {
process.env.PASCAL_SCENE_API_TOKEN = 'secret'
const request = new Request('https://editor.example/api/scenes', {
headers: {
authorization: 'Bearer secret',
host: 'editor.example',
},
})
expect(guardSceneApiRequest(request)).toBeNull()
})
test('applies configured CORS origins for preflight', () => {
process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example'
const request = new Request('https://editor.example/api/scenes', {
method: 'OPTIONS',
headers: {
host: 'editor.example',
origin: 'https://app.example',
},
})
const response = sceneApiPreflight(request)
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example')
})
+180
View File
@@ -0,0 +1,180 @@
import { timingSafeEqual } from 'node:crypto'
import { NextResponse } from 'next/server'
const DEFAULT_RATE_LIMIT_PER_MINUTE = 120
const WINDOW_MS = 60_000
const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
const ALLOWED_HEADERS = 'authorization, content-type, if-match, last-event-id, x-pascal-scene-token'
type RateBucket = {
resetAt: number
count: number
}
const rateBuckets = new Map<string, RateBucket>()
export function sceneApiPreflight(request: Request): NextResponse {
const guard = guardSceneApiRequest(request, { skipRateLimit: true, skipAuth: true })
if (guard) return guard
return withSceneApiHeaders(request, new NextResponse(null, { status: 204 }))
}
export function guardSceneApiRequest(
request: Request,
opts: { skipRateLimit?: boolean; skipAuth?: boolean } = {},
): NextResponse | null {
const originError = validateOrigin(request)
if (originError) return originError
if (!opts.skipAuth) {
const authError = validateAuth(request)
if (authError) return authError
}
if (!opts.skipRateLimit) {
const rateError = validateRateLimit(request)
if (rateError) return rateError
}
return null
}
export function sceneApiJson(request: Request, body: unknown, init?: ResponseInit): NextResponse {
return withSceneApiHeaders(request, NextResponse.json(body, init))
}
export function withSceneApiHeaders<T extends Response>(request: Request, response: T): T {
const origin = request.headers.get('origin')
if (origin && isOriginAllowed(request, origin)) {
response.headers.set('Access-Control-Allow-Origin', origin)
response.headers.append('Vary', 'Origin')
}
response.headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS)
response.headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS)
response.headers.set('Cache-Control', response.headers.get('Cache-Control') ?? 'no-store')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
function validateOrigin(request: Request): NextResponse | null {
const origin = request.headers.get('origin')
if (!origin || isOriginAllowed(request, origin)) return null
return sceneApiJson(request, { error: 'origin_not_allowed' }, { status: 403 })
}
function validateAuth(request: Request): NextResponse | null {
const token = process.env.PASCAL_SCENE_API_TOKEN
if (!token) {
if (isLoopbackRequest(request)) return null
return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 })
}
const supplied = bearerToken(request) ?? request.headers.get('x-pascal-scene-token')
if (supplied && safeEqual(supplied, token)) return null
return sceneApiJson(request, { error: 'unauthorized' }, { status: 401 })
}
function validateRateLimit(request: Request): NextResponse | null {
const limit = rateLimitPerMinute()
if (limit <= 0) return null
const now = Date.now()
const key = clientIp(request)
const bucket = rateBuckets.get(key)
if (!bucket || bucket.resetAt <= now) {
rateBuckets.set(key, { count: 1, resetAt: now + WINDOW_MS })
return null
}
bucket.count++
if (bucket.count <= limit) return null
const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
const response = sceneApiJson(request, { error: 'rate_limited' }, { status: 429 })
response.headers.set('Retry-After', String(retryAfter))
return response
}
function bearerToken(request: Request): string | null {
const header = request.headers.get('authorization')
if (!header) return null
const match = header.match(/^Bearer\s+(.+)$/i)
return match?.[1] ?? null
}
function safeEqual(a: string, b: string): boolean {
const aBuffer = Buffer.from(a)
const bBuffer = Buffer.from(b)
if (aBuffer.length !== bBuffer.length) return false
return timingSafeEqual(aBuffer, bBuffer)
}
function rateLimitPerMinute(): number {
const raw = process.env.PASCAL_SCENE_API_RATE_LIMIT
if (!raw) return DEFAULT_RATE_LIMIT_PER_MINUTE
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : DEFAULT_RATE_LIMIT_PER_MINUTE
}
function clientIp(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
if (forwarded) return forwarded
return request.headers.get('x-real-ip') ?? 'unknown'
}
function isOriginAllowed(request: Request, origin: string): boolean {
if (isSameOrigin(request, origin)) return true
const parsed = parseUrl(origin)
if (!parsed) return false
if (isLoopbackHostname(parsed.hostname)) return true
return configuredOrigins().has(normalizeOrigin(parsed))
}
function configuredOrigins(): Set<string> {
const raw = process.env.PASCAL_SCENE_API_ORIGINS
if (!raw) return new Set()
return new Set(
raw
.split(',')
.map((part) => parseUrl(part.trim()))
.filter((url): url is URL => url !== null)
.map(normalizeOrigin),
)
}
function isSameOrigin(request: Request, origin: string): boolean {
const parsedOrigin = parseUrl(origin)
if (!parsedOrigin) return false
const requestUrl = new URL(request.url)
return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl)
}
function isLoopbackRequest(request: Request): boolean {
const host = request.headers.get('host') ?? new URL(request.url).host
return isLoopbackHostname(stripPort(host))
}
function isLoopbackHostname(hostname: string): boolean {
const h = hostname.toLowerCase()
return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1'
}
function parseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}
function normalizeOrigin(url: URL): string {
return `${url.protocol}//${url.host}`.toLowerCase()
}
function stripPort(host: string): string {
if (host.startsWith('[')) {
const end = host.indexOf(']')
return end === -1 ? host : host.slice(1, end)
}
return host.split(':')[0] ?? host
}
@@ -2,6 +2,12 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'
describe('getSceneStore', () => {
beforeEach(() => {
mock.module('@pascal-app/mcp/operations', () => ({
createSceneOperations: ({ store }: { store: unknown }) => ({
__store: store,
hasStore: true,
}),
}))
mock.module('@pascal-app/mcp/storage', () => {
let callCount = 0
return {
@@ -55,4 +61,14 @@ describe('getSceneStore', () => {
expect(first).not.toBe(second)
})
test('getSceneOperations wraps the cached store', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const store = await mod.getSceneStore()
const operations = await mod.getSceneOperations()
expect((operations as unknown as { __store: unknown }).__store).toBe(store)
})
})
+21 -10
View File
@@ -1,8 +1,4 @@
// TODO: auth — every call in this module currently runs unauthenticated.
// v0.1 is scoped to local-first use on a developer machine. A hosted editor
// should pass request-scoped user context through this factory before exposing
// these routes publicly.
import type { SceneOperations } from '@pascal-app/mcp/operations'
import type { SceneStore } from '@pascal-app/mcp/storage'
/**
@@ -10,18 +6,32 @@ import type { SceneStore } from '@pascal-app/mcp/storage'
* dynamically imported — we cache the in-flight promise so concurrent calls
* during a cold start share a single instantiation.
*/
let cached: Promise<SceneStore> | null = null
let cachedStore: Promise<SceneStore> | null = null
let cachedOperations: Promise<SceneOperations> | null = null
export function getSceneStore(): Promise<SceneStore> {
if (!cached) {
cached = (async () => {
if (!cachedStore) {
cachedStore = (async () => {
const mod = (await import('@pascal-app/mcp/storage')) as {
createSceneStore: (env?: NodeJS.ProcessEnv) => Promise<SceneStore>
}
return mod.createSceneStore(process.env)
})()
}
return cached
return cachedStore
}
export function getSceneOperations(): Promise<SceneOperations> {
if (!cachedOperations) {
cachedOperations = (async () => {
const store = await getSceneStore()
const mod = (await import('@pascal-app/mcp/operations')) as {
createSceneOperations: (options: { store: SceneStore }) => SceneOperations
}
return mod.createSceneOperations({ store })
})()
}
return cachedOperations
}
/**
@@ -29,5 +39,6 @@ export function getSceneStore(): Promise<SceneStore> {
* callers.
*/
export function __resetSceneStoreForTests(): void {
cached = null
cachedStore = null
cachedOperations = null
}
File diff suppressed because it is too large Load Diff