6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { permissionSatisfies } from '@sim/platform-authz/workspace'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
createTableColumnBodySchema,
|
|
deleteTableColumnBodySchema,
|
|
updateTableColumnBodySchema,
|
|
} from '@/lib/api/contracts/tables'
|
|
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
|
import type { MultipartError } from '@/lib/core/utils/multipart'
|
|
import type { ColumnDefinition, Filter, TableDefinition, TablePredicate } from '@/lib/table'
|
|
import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table'
|
|
import { typeMetadataOf } from '@/lib/table/column-types'
|
|
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
|
|
import { TableLockedError } from '@/lib/table/mutation-locks'
|
|
import { isTablePredicate } from '@/lib/table/query-builder/converters'
|
|
import { validateStoragePredicate } from '@/lib/table/query-builder/validate'
|
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
|
import { getWorkspaceOrganizationId } from '@/lib/workspaces/utils'
|
|
|
|
/**
|
|
* Gate for the v2 tables HTTP API (`tables-v2-api` flag). Returns a 404 response
|
|
* when the flag is off for the caller — the surface behaves as if it doesn't
|
|
* exist — or `null` to proceed. Gated by userId + the workspace's org cohort.
|
|
*
|
|
* **Call this AFTER the authz check, never before.** Ahead of authz it does a
|
|
* primary-DB read keyed on a caller-supplied `workspaceId`, and the 404-vs-403
|
|
* split tells an unauthorized caller whether that workspace's org is in the
|
|
* rollout cohort.
|
|
*/
|
|
export async function tablesV2GateError(
|
|
userId: string,
|
|
workspaceId: string
|
|
): Promise<NextResponse | null> {
|
|
const orgId = await getWorkspaceOrganizationId(workspaceId)
|
|
if (await isFeatureEnabled('tables-v2-api', { userId, orgId })) return null
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
}
|
|
|
|
/**
|
|
* Maps a {@link TableLockedError} thrown by the service layer to a 423 response
|
|
* carrying `{ error, lock }`; returns `null` for any other error so the caller
|
|
* falls through to its existing handling. Call this as the FIRST statement of a
|
|
* table route's catch block — otherwise `rowWriteErrorResponse` (and the other
|
|
* substring funnels) turn the lock error into a generic 500.
|
|
*
|
|
* The body deliberately omits a `details` array: the client's `isValidationError`
|
|
* treats any `ApiClientError` with array-valued `details` as a field-validation
|
|
* error and swallows its toast, so a lock rejection must not carry one.
|
|
*/
|
|
export function tableLockErrorResponse(error: unknown): NextResponse | null {
|
|
if (error instanceof TableLockedError) {
|
|
return NextResponse.json({ error: error.message, lock: error.lock }, { status: 423 })
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Validates a wire `filter` (either grammar) against the table's column schema,
|
|
* returning a 400 response on a bad field (or `null` when the filter is valid or
|
|
* absent). Shared by the routes that accept a filter (`delete-async`,
|
|
* `cancel-runs`, `columns/run`) so a bad field fails fast with a clear message.
|
|
*
|
|
* Pass the WIRE filter, not the `toLegacyFilter` downgrade: the downgrade
|
|
* compiles cleanly through `buildFilterClause` even when a predicate leaf names
|
|
* a column that doesn't exist, so validating only the downgraded form lets a
|
|
* typo'd field become a clause that silently matches nothing — a no-op where
|
|
* the sync bulk routes 400.
|
|
*/
|
|
export function tableFilterError(
|
|
filter: Filter | TablePredicate | undefined,
|
|
columns: ColumnDefinition[]
|
|
): NextResponse | null {
|
|
if (!filter) return null
|
|
try {
|
|
if (isTablePredicate(filter)) {
|
|
// These routes speak storage keys (session grid uses column ids; system
|
|
// columns keep their names) — same keying the sync bulk routes validate.
|
|
validateStoragePredicate(filter, columns)
|
|
} else {
|
|
buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, columns)
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
if (error instanceof TableQueryValidationError) {
|
|
return NextResponse.json({ error: error.message }, { status: 400 })
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const logger = createLogger('TableUtils')
|
|
|
|
/**
|
|
* Deepest `Error` message in the cause chain. Drizzle wraps DB errors in a
|
|
* `DrizzleQueryError` whose own message is just the failed SQL — substring
|
|
* classification must look at the root cause.
|
|
*/
|
|
export function rootErrorMessage(error: unknown): string {
|
|
let current: unknown = error
|
|
while (current instanceof Error && current.cause instanceof Error) {
|
|
current = current.cause
|
|
}
|
|
return toError(current).message
|
|
}
|
|
|
|
/**
|
|
* Known user-facing row-write failures (service validation + the best-effort
|
|
* plan row-limit check). Anything outside this list stays a generic 500 —
|
|
* unknown errors can carry SQL/internals that don't belong in a toast.
|
|
*/
|
|
const ROW_WRITE_ERROR_PATTERNS = [
|
|
'row limit',
|
|
'Insufficient capacity',
|
|
'Schema validation',
|
|
'must be unique',
|
|
'must be valid',
|
|
'must be string',
|
|
'must be number',
|
|
'must be boolean',
|
|
'unique column',
|
|
'Unique constraint violation',
|
|
'Row size exceeds',
|
|
'conflictTarget',
|
|
'Upsert requires',
|
|
'Rows not found',
|
|
'Filter is required',
|
|
] as const
|
|
|
|
/**
|
|
* Maps a known user-facing row-write failure to a 400 carrying the real message
|
|
* (so client toasts can show the actual reason); `null` when the error is
|
|
* unrecognized and the caller should log it and return its generic 500.
|
|
*/
|
|
export function rowWriteErrorResponse(error: unknown): NextResponse | null {
|
|
// A lock violation is a 423, not a 400/500 — check before the pattern match,
|
|
// which would otherwise let it fall through to the caller's generic 500.
|
|
const lockResponse = tableLockErrorResponse(error)
|
|
if (lockResponse) return lockResponse
|
|
|
|
const message = rootErrorMessage(error)
|
|
|
|
if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) {
|
|
return NextResponse.json({ error: message }, { status: 400 })
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Next.js buffers the request body for the proxy and silently truncates it past this
|
|
* size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV
|
|
* import routes reject bodies over the cap up front; larger files use the async
|
|
* direct-to-storage path instead.
|
|
*/
|
|
export const CSV_IMPORT_PROXY_BODY_CAP_BYTES = 10 * 1024 * 1024
|
|
|
|
/** 413 response when a synchronous CSV upload would exceed (and be truncated at) the proxy cap; `null` otherwise. */
|
|
export function csvProxyBodyCapResponse(request: { headers: Headers }): NextResponse | null {
|
|
const contentLength = Number(request.headers.get('content-length') ?? 0)
|
|
if (contentLength > CSV_IMPORT_PROXY_BODY_CAP_BYTES) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'File too large to import through the server. Files over 10MB import in the background.',
|
|
},
|
|
{ status: 413 }
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Maps a {@link MultipartError} from the streaming CSV parser to its HTTP response. */
|
|
export function multipartErrorResponse(error: MultipartError): NextResponse {
|
|
if (error.code === 'FILE_TOO_LARGE') {
|
|
return NextResponse.json({ error: 'CSV import file exceeds maximum size' }, { status: 413 })
|
|
}
|
|
const message =
|
|
error.code === 'NO_FILE' ? 'CSV file is required' : `Invalid CSV upload: ${error.message}`
|
|
return NextResponse.json({ error: message }, { status: 400 })
|
|
}
|
|
|
|
interface TableAccessResult {
|
|
hasAccess: true
|
|
table: TableDefinition
|
|
}
|
|
|
|
interface TableAccessDenied {
|
|
hasAccess: false
|
|
notFound?: boolean
|
|
reason?: string
|
|
}
|
|
|
|
export type TableAccessCheck = TableAccessResult | TableAccessDenied
|
|
|
|
export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 }
|
|
|
|
interface ApiErrorResponse {
|
|
error: string
|
|
details?: unknown
|
|
}
|
|
|
|
/**
|
|
* Check if a user has read access to a table.
|
|
* Read access requires any workspace permission (read, write, or admin).
|
|
*/
|
|
async function checkTableAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
|
|
const table = await getTableById(tableId)
|
|
|
|
if (!table) {
|
|
return { hasAccess: false, notFound: true }
|
|
}
|
|
|
|
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
|
if (userPermission !== null) {
|
|
return { hasAccess: true, table }
|
|
}
|
|
|
|
return { hasAccess: false, reason: 'User does not have access to this table' }
|
|
}
|
|
|
|
/**
|
|
* Check if a user has write access to a table.
|
|
* Write access requires write or admin workspace permission.
|
|
*/
|
|
async function checkTableWriteAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
|
|
const table = await getTableById(tableId)
|
|
|
|
if (!table) {
|
|
return { hasAccess: false, notFound: true }
|
|
}
|
|
|
|
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
|
if (permissionSatisfies(userPermission, 'write')) {
|
|
return { hasAccess: true, table }
|
|
}
|
|
|
|
return { hasAccess: false, reason: 'User does not have write access to this table' }
|
|
}
|
|
|
|
/**
|
|
* Access check returning `{ ok, table }` or `{ ok: false, status }`.
|
|
* Uses workspace permissions only.
|
|
*/
|
|
export async function checkAccess(
|
|
tableId: string,
|
|
userId: string,
|
|
level: 'read' | 'write' | 'admin' = 'read'
|
|
): Promise<AccessResult> {
|
|
const table = await getTableById(tableId)
|
|
|
|
if (!table) {
|
|
return { ok: false, status: 404 }
|
|
}
|
|
|
|
const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
|
|
const hasAccess = permissionSatisfies(permission, level)
|
|
|
|
return hasAccess ? { ok: true, table } : { ok: false, status: 403 }
|
|
}
|
|
|
|
export function accessError(
|
|
result: { ok: false; status: 404 | 403 },
|
|
requestId: string,
|
|
context?: string
|
|
): NextResponse {
|
|
const message = result.status === 404 ? 'Table not found' : 'Access denied'
|
|
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
|
|
return NextResponse.json({ error: message }, { status: result.status })
|
|
}
|
|
|
|
/**
|
|
* Converts a TableAccessDenied result to an appropriate HTTP response.
|
|
* Use with checkTableAccess or checkTableWriteAccess.
|
|
*/
|
|
export function tableAccessError(
|
|
result: TableAccessDenied,
|
|
requestId: string,
|
|
context?: string
|
|
): NextResponse {
|
|
const status = result.notFound ? 404 : 403
|
|
const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied')
|
|
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
|
|
return NextResponse.json({ error: message }, { status })
|
|
}
|
|
|
|
async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
|
|
const table = await getTableById(tableId)
|
|
return table?.workspaceId === workspaceId
|
|
}
|
|
|
|
export function errorResponse(
|
|
message: string,
|
|
status: number,
|
|
details?: unknown
|
|
): NextResponse<ApiErrorResponse> {
|
|
const body: ApiErrorResponse = { error: message }
|
|
if (details !== undefined) {
|
|
body.details = details
|
|
}
|
|
return NextResponse.json(body, { status })
|
|
}
|
|
|
|
export function badRequestResponse(message: string, details?: unknown) {
|
|
return errorResponse(message, 400, details)
|
|
}
|
|
|
|
export function unauthorizedResponse(message = 'Authentication required') {
|
|
return errorResponse(message, 401)
|
|
}
|
|
|
|
export function forbiddenResponse(message = 'Access denied') {
|
|
return errorResponse(message, 403)
|
|
}
|
|
|
|
export function notFoundResponse(message = 'Resource not found') {
|
|
return errorResponse(message, 404)
|
|
}
|
|
|
|
export function serverErrorResponse(message = 'Internal server error') {
|
|
return errorResponse(message, 500)
|
|
}
|
|
|
|
/**
|
|
* Re-exports from `lib/api/contracts/tables` so existing routes that import
|
|
* these names keep working while sharing a single source of truth.
|
|
*/
|
|
export const CreateColumnSchema = createTableColumnBodySchema
|
|
export const UpdateColumnSchema = updateTableColumnBodySchema
|
|
export const DeleteColumnSchema = deleteTableColumnBodySchema
|
|
|
|
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
|
|
return {
|
|
// Preserve the stable column id — it's the row-data storage key, so dropping
|
|
// it makes clients fall back to `name` and miss id-keyed cell values.
|
|
...(col.id ? { id: col.id } : {}),
|
|
name: col.name,
|
|
type: col.type,
|
|
required: col.required ?? false,
|
|
unique: col.unique ?? false,
|
|
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
|
|
// Type-specific metadata is forwarded generically: naming keys here meant a
|
|
// new type's metadata was stored server-side but silently never returned.
|
|
...typeMetadataOf(col),
|
|
}
|
|
}
|