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
288 lines
9.2 KiB
TypeScript
288 lines
9.2 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import type { ZodError } from 'zod'
|
|
import { getValidationErrorMessage, isZodError, validationErrorResponse } from '@/lib/api/server'
|
|
import { buildRateLimitHeaders, recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context'
|
|
import { PERSONAL_KEY_DENIED, WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
|
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
|
import type { SubscriptionPlan } from '@/lib/core/rate-limiter'
|
|
import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter'
|
|
import { generateRequestId } from '@/lib/core/utils/request'
|
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
|
import {
|
|
getWorkspaceBilledAccountUserId,
|
|
getWorkspaceBillingSettings,
|
|
} from '@/lib/workspaces/utils'
|
|
import { authenticateV1Request } from '@/app/api/v1/auth'
|
|
|
|
const logger = createLogger('V1Middleware')
|
|
const rateLimiter = new RateLimiter()
|
|
|
|
/**
|
|
* Endpoint labels for public API auth/rate-limit telemetry. Version-neutral: the
|
|
* v1 and v2 public surfaces share the same `authenticateV1Request` + `api-endpoint`
|
|
* rate bucket, so the label is only a log/metric dimension, not a policy switch.
|
|
*/
|
|
export type ApiEndpoint =
|
|
| 'logs'
|
|
| 'logs-detail'
|
|
| 'workflows'
|
|
| 'workflow-detail'
|
|
| 'workflow-deploy'
|
|
| 'workflow-rollback'
|
|
| 'workflow-export'
|
|
| 'workflow-import'
|
|
| 'audit-logs'
|
|
| 'tables'
|
|
| 'table-detail'
|
|
| 'table-rows'
|
|
| 'table-row-detail'
|
|
| 'table-columns'
|
|
| 'files'
|
|
| 'file-detail'
|
|
| 'knowledge'
|
|
| 'knowledge-detail'
|
|
| 'knowledge-search'
|
|
| 'copilot-chat'
|
|
| 'v2-tables'
|
|
| 'v2-table-rows'
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean
|
|
remaining: number
|
|
resetAt: Date
|
|
/**
|
|
* Bucket capacity, matching what `remaining` counts down from. Zero on the
|
|
* paths that never reached the bucket (auth failure, checker error); those
|
|
* carry `error` and publish no rate-limit headers.
|
|
*/
|
|
limit: number
|
|
retryAfterMs?: number
|
|
userId?: string
|
|
workspaceId?: string
|
|
keyType?: 'personal' | 'workspace'
|
|
error?: string
|
|
}
|
|
|
|
export interface AuthorizedRequest {
|
|
requestId: string
|
|
userId: string
|
|
rateLimit: RateLimitResult
|
|
}
|
|
|
|
export async function checkRateLimit(
|
|
request: NextRequest,
|
|
endpoint: ApiEndpoint = 'logs'
|
|
): Promise<RateLimitResult> {
|
|
try {
|
|
const auth = await authenticateV1Request(request)
|
|
if (!auth.authenticated) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
limit: 0,
|
|
resetAt: new Date(),
|
|
error: auth.error,
|
|
}
|
|
}
|
|
|
|
const userId = auth.userId!
|
|
const subscription = await getHighestPrioritySubscription(userId)
|
|
|
|
const result = await rateLimiter.checkRateLimitWithSubscription(
|
|
userId,
|
|
subscription,
|
|
'api-endpoint',
|
|
false
|
|
)
|
|
|
|
if (!result.allowed) {
|
|
logger.warn(`Rate limit exceeded for user ${userId}`, {
|
|
endpoint,
|
|
remaining: result.remaining,
|
|
resetAt: result.resetAt,
|
|
})
|
|
}
|
|
|
|
const plan = (subscription?.plan || 'free') as SubscriptionPlan
|
|
const config = getRateLimit(plan, 'api-endpoint')
|
|
|
|
/** Recorded here — the one place the bucket is actually consulted. */
|
|
recordRateLimitSnapshot(request, {
|
|
limit: config.maxTokens,
|
|
remaining: result.remaining,
|
|
resetAt: result.resetAt,
|
|
})
|
|
|
|
return {
|
|
allowed: result.allowed,
|
|
remaining: result.remaining,
|
|
resetAt: result.resetAt,
|
|
/**
|
|
* The bucket's capacity, not its refill rate. `remaining` is the token
|
|
* count left in that bucket, and `createBucketConfig` sets
|
|
* `maxTokens = refillRate * burstMultiplier` — so reporting `refillRate`
|
|
* here published an `X-RateLimit-Limit` smaller than the
|
|
* `X-RateLimit-Remaining` beside it (e.g. limit 200, remaining 399), and
|
|
* any client computing `used = limit - remaining` got a negative number.
|
|
* Both headers must describe the same quantity.
|
|
*/
|
|
limit: config.maxTokens,
|
|
retryAfterMs: result.retryAfterMs,
|
|
userId,
|
|
workspaceId: auth.workspaceId,
|
|
keyType: auth.keyType,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Rate limit check error', { error })
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
limit: 0,
|
|
resetAt: new Date(Date.now() + 60000),
|
|
error: 'Rate limit check failed',
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authenticates and rate-limits a v1 API request.
|
|
* Returns NextResponse on failure, AuthorizedRequest on success.
|
|
*/
|
|
export async function authenticateRequest(
|
|
request: NextRequest,
|
|
endpoint: ApiEndpoint
|
|
): Promise<AuthorizedRequest | NextResponse> {
|
|
const requestId = generateRequestId()
|
|
const rateLimit = await checkRateLimit(request, endpoint)
|
|
if (!rateLimit.allowed) {
|
|
return createRateLimitResponse(rateLimit)
|
|
}
|
|
return { requestId, userId: rateLimit.userId!, rateLimit }
|
|
}
|
|
|
|
export function createRateLimitResponse(result: RateLimitResult): NextResponse {
|
|
/**
|
|
* An authentication failure never reaches the token bucket, so there is no
|
|
* limit to report. Publishing a placeholder told unauthenticated callers they
|
|
* had been throttled and handed monitoring a quota that does not exist.
|
|
*/
|
|
if (result.error) {
|
|
return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const retryAfterSeconds = result.retryAfterMs
|
|
? Math.ceil(result.retryAfterMs / 1000)
|
|
: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Rate limit exceeded',
|
|
message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`,
|
|
retryAfter: result.resetAt.getTime(),
|
|
},
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
...buildRateLimitHeaders(result),
|
|
'Retry-After': retryAfterSeconds.toString(),
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Verify that the API key is allowed to access the requested workspace.
|
|
*
|
|
* Enforces two policies:
|
|
* - A workspace-scoped key may only target its own workspace.
|
|
* - A personal key is rejected when the workspace has disabled personal API
|
|
* keys (`allowPersonalApiKeys = false`). Other surfaces enforcing the same
|
|
* policy share `PERSONAL_KEY_DENIED`.
|
|
*/
|
|
export async function checkWorkspaceScope(
|
|
rateLimit: RateLimitResult,
|
|
requestedWorkspaceId: string
|
|
): Promise<NextResponse | null> {
|
|
if (
|
|
rateLimit.keyType === 'workspace' &&
|
|
rateLimit.workspaceId &&
|
|
rateLimit.workspaceId !== requestedWorkspaceId
|
|
) {
|
|
return NextResponse.json({ error: WORKSPACE_KEY_SCOPE_DENIED }, { status: 403 })
|
|
}
|
|
|
|
if (rateLimit.keyType === 'personal') {
|
|
const settings = await getWorkspaceBillingSettings(requestedWorkspaceId)
|
|
if (!settings?.allowPersonalApiKeys) {
|
|
return NextResponse.json({ error: PERSONAL_KEY_DENIED }, { status: 403 })
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Resolves the usage actor for a workspace-scoped v1 request. Personal keys
|
|
* identify their human owner; shared workspace keys use the billed account as
|
|
* the explicit system actor because the credential does not identify a human.
|
|
*/
|
|
export async function resolveWorkspaceRequestActor(
|
|
rateLimit: RateLimitResult,
|
|
workspaceId: string
|
|
): Promise<string | null> {
|
|
if (rateLimit.keyType === 'workspace') {
|
|
return getWorkspaceBilledAccountUserId(workspaceId)
|
|
}
|
|
return rateLimit.userId ?? null
|
|
}
|
|
|
|
/**
|
|
* Validates workspace-scoped API key bounds and the user's workspace permission.
|
|
* Returns null on success, NextResponse on failure.
|
|
*/
|
|
export async function validateWorkspaceAccess(
|
|
rateLimit: RateLimitResult,
|
|
userId: string,
|
|
workspaceId: string,
|
|
level: PermissionType = 'read'
|
|
): Promise<NextResponse | null> {
|
|
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
|
|
if (scopeError) return scopeError
|
|
|
|
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
|
if (!permissionSatisfies(permission, level)) {
|
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Shared 400 handler for v1 contract validation failures.
|
|
*
|
|
* `parseRequest`'s default reports the literal `"Validation error"`, which tells
|
|
* a caller nothing about which field was wrong — the schema already produced a
|
|
* specific message, and the default discards it. Surfacing the first issue keeps
|
|
* `details` intact while making the common case self-explanatory.
|
|
*
|
|
* Pass as `parseRequest(contract, request, context, { validationErrorResponse:
|
|
* v1ValidationErrorResponse })`. Routes with a more specific message of their
|
|
* own (for example `'Invalid workflow ID'`) should keep it.
|
|
*/
|
|
export function v1ValidationErrorResponse(error: ZodError, fallback = 'Invalid request') {
|
|
return validationErrorResponse(error, getValidationErrorMessage(error, fallback))
|
|
}
|
|
|
|
/**
|
|
* v1 counterpart to `validationErrorResponseFromError` for unknown caught
|
|
* values: returns a 400 naming the failing field when the error is a
|
|
* `ZodError`, otherwise `null` so the caller can keep handling it.
|
|
*/
|
|
export function v1ValidationErrorResponseFromError(
|
|
error: unknown,
|
|
fallback = 'Invalid request'
|
|
): NextResponse | null {
|
|
return isZodError(error) ? v1ValidationErrorResponse(error, fallback) : null
|
|
}
|