Files
WeHub Mirror 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
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

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
}