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 })
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user