chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
import { type NextRequest, NextResponse } from 'next/server'
import { ZodError, type z } from 'zod'
import type {
AnyApiRouteContract,
ApiSchema,
ContractBody,
ContractHeaders,
ContractParams,
ContractQuery,
} from '@/lib/api/contracts'
import { env } from '@/lib/core/config/env'
import {
assertContentLengthWithinLimit,
isPayloadSizeLimitError,
readStreamToBufferWithLimit,
} from '@/lib/core/utils/stream-limits'
/**
* Default upper bound on the JSON request body that contract routes will read
* and parse into memory. Next.js App Router imposes no body cap, so without
* this an unauthenticated caller could buffer an arbitrarily large body before
* schema validation runs. Override per-route via `ParseRequestOptions.maxBodyBytes`.
* Falls back to 50 MB if the env value is missing or non-numeric so a misconfig
* can never silently disable the cap (a NaN limit would never reject).
*/
export const DEFAULT_MAX_JSON_BODY_BYTES =
Number.parseInt(env.API_MAX_JSON_BODY_BYTES, 10) || 50 * 1024 * 1024
export interface ValidationErrorBody {
error: string
details: z.core.$ZodIssue[]
}
type ValidationResult<S extends ApiSchema> =
| { success: true; data: z.output<S> }
| { success: false; response: NextResponse<unknown>; error: z.ZodError }
export interface ParsedRequest<C extends AnyApiRouteContract> {
params: ContractParams<C>
query: ContractQuery<C>
body: ContractBody<C>
headers: ContractHeaders<C>
}
interface RouteContextWithParams {
params?:
| Promise<Record<string, string | string[] | undefined>>
| Record<string, string | string[] | undefined>
}
export interface ParseRequestOptions {
validationErrorResponse?: (error: z.ZodError) => NextResponse<unknown>
invalidJsonResponse?: () => NextResponse<unknown>
invalidJson?: 'response' | 'throw'
/**
* Maximum number of bytes to read for the JSON body before rejecting with a
* 413. Defaults to {@link DEFAULT_MAX_JSON_BODY_BYTES}. Raise this only for
* routes that legitimately accept large JSON payloads (e.g. inline file uploads).
*/
maxBodyBytes?: number
}
export function serializeZodIssues(error: z.ZodError): z.core.$ZodIssue[] {
return error.issues
}
export function isZodError(error: unknown): error is z.ZodError {
return error instanceof ZodError
}
export function validationErrorResponse(
error: z.ZodError,
message = 'Validation error',
status = 400
): NextResponse<ValidationErrorBody> {
return NextResponse.json({ error: message, details: serializeZodIssues(error) }, { status })
}
export function getValidationErrorMessage(
error: z.ZodError,
fallback = 'Invalid request data'
): string {
return error.issues[0]?.message || fallback
}
export function validationErrorResponseFromError(
error: unknown,
message = 'Validation error',
status = 400
): NextResponse<ValidationErrorBody> | null {
if (!isZodError(error)) return null
return validationErrorResponse(error, message, status)
}
const REQUEST_BODY_LABEL = 'Request body'
/**
* Reads the JSON body while enforcing a byte cap. The body is read through a
* size-limited stream so chunked/streamed bodies are bounded even when the
* `content-length` header is absent or lies about the true size. When no
* readable stream is available (e.g. a mocked request) the content-length guard
* is the only bound and parsing falls back to {@link Request.json}. Decoding
* uses {@link TextDecoder} so a leading UTF-8 BOM is stripped, matching the spec
* "UTF-8 decode" behavior of `request.json()`.
*/
async function readJsonBodyWithLimit(request: Request, maxBytes: number): Promise<unknown> {
assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL)
const stream = request.body
if (!stream) {
return request.json()
}
const buffer = await readStreamToBufferWithLimit(stream, {
maxBytes,
label: REQUEST_BODY_LABEL,
})
return JSON.parse(new TextDecoder().decode(buffer))
}
export async function parseJsonBody(
request: Request,
invalidJson: ParseRequestOptions['invalidJson'] = 'response',
maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES
): Promise<
| { success: true; data: unknown }
| {
success: false
reason: 'too_large' | 'invalid_json'
response: NextResponse<{ error: string }>
}
> {
try {
return { success: true, data: await readJsonBodyWithLimit(request, maxBytes) }
} catch (error) {
if (invalidJson === 'throw') throw error
if (isPayloadSizeLimitError(error)) {
return {
success: false,
reason: 'too_large',
response: NextResponse.json(
{ error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` },
{ status: 413 }
),
}
}
return {
success: false,
reason: 'invalid_json',
response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }),
}
}
}
/**
* Reads an entirely optional JSON body with the standard byte cap. An absent
* or empty body resolves to `{ success: true, data: undefined }`; malformed
* JSON and oversized payloads return the standard 400/413 error responses.
* Use for endpoints whose body may be omitted altogether (e.g. optional
* metadata on a deploy call) — `parseJsonBody` rejects empty bodies.
*/
export async function parseOptionalJsonBody(
request: Request,
maxBytes: number = DEFAULT_MAX_JSON_BODY_BYTES
): Promise<
{ success: true; data: unknown } | { success: false; response: NextResponse<{ error: string }> }
> {
try {
assertContentLengthWithinLimit(request.headers, maxBytes, REQUEST_BODY_LABEL)
const stream = request.body
const text = stream
? new TextDecoder().decode(
await readStreamToBufferWithLimit(stream, { maxBytes, label: REQUEST_BODY_LABEL })
)
: await request.text()
if (!text.trim()) {
return { success: true, data: undefined }
}
return { success: true, data: JSON.parse(text) }
} catch (error) {
if (isPayloadSizeLimitError(error)) {
return {
success: false,
response: NextResponse.json(
{ error: `Request body exceeds the maximum allowed size of ${maxBytes} bytes` },
{ status: 413 }
),
}
}
return {
success: false,
response: NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }),
}
}
}
export function searchParamsToObject(
searchParams: URLSearchParams
): Record<string, string | string[]> {
const output: Record<string, string | string[]> = {}
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key)
output[key] = values.length > 1 ? values : (values[0] ?? '')
}
return output
}
export function headersToObject(headers: Headers): Record<string, string> {
const output: Record<string, string> = {}
headers.forEach((value, key) => {
output[key] = value
})
return output
}
async function parseContextParams<TContext>(
context: TContext
): Promise<Record<string, string | string[] | undefined>> {
const candidate = context as RouteContextWithParams
if (!candidate.params) return {}
return await candidate.params
}
function shouldReadJsonBody<C extends AnyApiRouteContract>(contract: C): boolean {
return Boolean(contract.body) && contract.method !== 'GET'
}
export async function parseRequest<C extends AnyApiRouteContract, TContext>(
contract: C,
request: NextRequest,
context: TContext,
options?: ParseRequestOptions
): Promise<
{ success: true; data: ParsedRequest<C> } | { success: false; response: NextResponse<unknown> }
> {
const rawParams = await parseContextParams(context)
const rawQuery = searchParamsToObject(request.nextUrl.searchParams)
const rawHeaders = headersToObject(request.headers)
let body: unknown
if (shouldReadJsonBody(contract)) {
const parsedBody = await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes)
if (!parsedBody.success) {
return options?.invalidJsonResponse && parsedBody.reason === 'invalid_json'
? { success: false, response: options.invalidJsonResponse() }
: parsedBody
}
body = parsedBody.data
}
const params = contract.params
? validateRequestSchema(contract.params, rawParams, options)
: undefined
if (params && !params.success) return params
const query = contract.query
? validateRequestSchema(contract.query, rawQuery, options)
: undefined
if (query && !query.success) return query
const headers = contract.headers
? validateRequestSchema(contract.headers, rawHeaders, options)
: undefined
if (headers && !headers.success) return headers
const parsedBody = contract.body ? validateRequestSchema(contract.body, body, options) : undefined
if (parsedBody && !parsedBody.success) return parsedBody
return {
success: true,
data: {
params: params?.data as ContractParams<C>,
query: query?.data as ContractQuery<C>,
headers: headers?.data as ContractHeaders<C>,
body: parsedBody?.data as ContractBody<C>,
},
}
}
function validateRequestSchema<S extends ApiSchema>(
schema: S,
data: unknown,
options?: ParseRequestOptions
): ValidationResult<S> {
const result = schema.safeParse(data)
if (!result.success) {
return {
success: false,
response: options?.validationErrorResponse
? options.validationErrorResponse(result.error)
: validationErrorResponse(result.error),
error: result.error,
}
}
return { success: true, data: result.data }
}