Files
simstudioai--sim/apps/sim/lib/api/contracts/data-drains.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

568 lines
19 KiB
TypeScript

import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { CADENCE_TYPES, DESTINATION_TYPES, SOURCE_TYPES } from '@/lib/data-drains/types'
/** AWS S3 bucket: 3-63 chars, lowercase alnum + . / -, see s3.ts for full rules. */
const S3_BUCKET_NAME_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/
const S3_IPV4_LIKE_RE = /^(\d{1,3}\.){3}\d{1,3}$/
const AWS_REGION_RE = /^[a-z]{2,}(-[a-z]+)+-\d+$/
/** GCS bucket component: lowercase alnum + _ / -, start/end alnum. Mirrors gcs.ts. */
const GCS_BUCKET_COMPONENT_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/
const GOOGLE_RESERVED_PREFIX_RE = /^(goog|google|g00gle)/i
const GOOGLE_CONTAINS_RE = /(google|g00gle)/i
function validateGcsBucketComponents(v: string): string | null {
if (v.length < 3 || v.length > 222) return 'bucket must be 3-222 characters'
const components = v.split('.')
for (const c of components) {
if (c.length < 1 || c.length > 63) {
return 'each dot-separated component must be 1-63 characters'
}
if (!GCS_BUCKET_COMPONENT_RE.test(c)) {
return 'each component must be lowercase, start/end alphanumeric, letters/digits/_/- only'
}
}
return null
}
/** Azure storage account: 3-24 lowercase alnum. */
const AZURE_ACCOUNT_NAME_RE = /^[a-z0-9]{3,24}$/
/** Azure container: 3-63 chars, lowercase alnum + single hyphens. */
const AZURE_CONTAINER_NAME_RE = /^[a-z0-9]([a-z0-9]|-(?!-))+[a-z0-9]$/
/** Azure Blob Storage endpoint suffixes (Public, US Gov, China, Germany). */
const AZURE_ENDPOINT_SUFFIXES = [
'blob.core.windows.net',
'blob.core.usgovcloudapi.net',
'blob.core.chinacloudapi.cn',
'blob.core.cloudapi.de',
] as const
/** BigQuery project / dataset / table identifiers. */
const BQ_PROJECT_ID_RE = /^([a-z][a-z0-9.-]{0,61}[a-z0-9]:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const BQ_DATASET_RE = /^[A-Za-z0-9_]{1,1024}$/
const BQ_TABLE_RE = /^[\p{L}\p{M}\p{N}\p{Pc}\p{Pd} ]{1,1024}$/u
/** Snowflake account + identifier shapes — mirrored from snowflake.ts. */
const SNOWFLAKE_ACCOUNT_ORG_RE = /^[A-Za-z0-9][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)+$/
/** First segment allows hyphens so org-account identifiers carrying a region/cloud suffix match. Mirrors snowflake.ts. */
const SNOWFLAKE_ACCOUNT_LOCATOR_RE =
/^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*){0,2}$/
const SNOWFLAKE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,254}$/
/** Reserved Sim-namespaced header names that cannot be reused as the webhook signature header. */
const RESERVED_WEBHOOK_SIGNATURE_HEADER_NAMES = new Set([
'authorization',
'content-type',
'user-agent',
'idempotency-key',
'x-sim-timestamp',
'x-sim-signature-version',
'x-sim-drain-id',
'x-sim-run-id',
'x-sim-source',
'x-sim-sequence',
'x-sim-row-count',
'x-sim-probe',
'x-sim-signature',
])
export const dataDrainSourceSchema = z.enum(SOURCE_TYPES)
export const dataDrainDestinationTypeSchema = z.enum(DESTINATION_TYPES)
export const dataDrainCadenceSchema = z.enum(CADENCE_TYPES)
export const dataDrainRunStatusSchema = z.enum(['running', 'success', 'failed'])
export const dataDrainRunTriggerSchema = z.enum(['cron', 'manual'])
export const dataDrainOrgParamsSchema = z.object({
id: z.string().min(1, 'organization id is required'),
})
export const dataDrainParamsSchema = z.object({
id: z.string().min(1, 'organization id is required'),
drainId: z.string().min(1, 'drain id is required'),
})
const drainNameSchema = z.string().trim().min(1, 'name is required').max(120)
const s3ConfigBodySchema = z.object({
bucket: z
.string()
.min(3, 'bucket must be 3-63 characters')
.max(63, 'bucket must be 3-63 characters')
.refine((v) => S3_BUCKET_NAME_RE.test(v), {
message: 'bucket must be lowercase, 3-63 chars, start/end alphanumeric',
})
.refine((v) => !v.includes('..'), { message: 'bucket must not contain consecutive dots' })
.refine((v) => !v.includes('-.') && !v.includes('.-'), {
message: 'bucket must not contain a dash adjacent to a dot',
})
.refine((v) => !S3_IPV4_LIKE_RE.test(v), { message: 'bucket must not look like an IP address' })
.refine((v) => !v.startsWith('xn--'), { message: 'bucket must not start with "xn--"' })
.refine((v) => !v.startsWith('sthree-'), { message: 'bucket must not start with "sthree-"' })
.refine((v) => !v.startsWith('amzn-s3-demo-'), {
message: 'bucket must not start with "amzn-s3-demo-" (reserved by AWS)',
})
.refine(
(v) =>
!v.endsWith('-s3alias') &&
!v.endsWith('--ol-s3') &&
!v.endsWith('.mrap') &&
!v.endsWith('--x-s3') &&
!v.endsWith('--table-s3'),
{
message:
'bucket must not end with reserved suffix (-s3alias, --ol-s3, .mrap, --x-s3, --table-s3)',
}
),
region: z
.string()
.min(1, 'region is required')
.max(32, 'region is too long')
.refine((v) => AWS_REGION_RE.test(v), {
message: 'region must look like an AWS region code, e.g. us-east-1',
}),
prefix: z
.string()
.max(512)
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
message: 'prefix must be at most 512 bytes (UTF-8)',
})
.optional(),
endpoint: z
.string()
.url()
.refine((v) => v.startsWith('https://'), { message: 'endpoint must use https://' })
.refine((value) => validateExternalUrl(value, 'endpoint').isValid, {
message: 'endpoint must be HTTPS and not point at a private, loopback, or metadata address',
})
.optional(),
forcePathStyle: z.boolean().optional(),
})
const s3CredentialsBodySchema = z.object({
accessKeyId: z.string().min(1, 'accessKeyId is required'),
secretAccessKey: z.string().min(1, 'secretAccessKey is required'),
})
const gcsConfigBodySchema = z.object({
bucket: z
.string()
.min(3, 'bucket must be 3-222 characters')
.max(222, 'bucket must be 3-222 characters')
.superRefine((v, ctx) => {
const err = validateGcsBucketComponents(v)
if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err })
})
.refine((v) => !S3_IPV4_LIKE_RE.test(v), { message: 'bucket must not look like an IP address' })
.refine((v) => !v.includes('..'), { message: 'bucket must not contain consecutive dots' })
.refine((v) => !v.includes('-.') && !v.includes('.-'), {
message: 'bucket must not contain "-." or ".-"',
})
.refine((v) => !GOOGLE_RESERVED_PREFIX_RE.test(v) && !GOOGLE_CONTAINS_RE.test(v), {
message: 'bucket name cannot begin with "goog" or contain "google" / close misspellings',
}),
prefix: z
.string()
.max(512)
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
message: 'prefix must be at most 512 bytes (UTF-8)',
})
.refine((v) => !v.startsWith('.well-known/acme-challenge/'), {
message: 'prefix must not start with ".well-known/acme-challenge/" (reserved by GCS)',
})
.optional(),
})
const gcsCredentialsBodySchema = z.object({
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
})
const azureBlobConfigBodySchema = z.object({
accountName: z
.string()
.min(1, 'accountName is required')
.refine((v) => AZURE_ACCOUNT_NAME_RE.test(v), {
message: 'accountName must be 3-24 lowercase letters or digits',
}),
containerName: z
.string()
.min(3, 'containerName must be 3-63 characters')
.max(63)
.refine((v) => AZURE_CONTAINER_NAME_RE.test(v), {
message: 'containerName must use lowercase letters, digits, or single hyphens',
}),
prefix: z.string().max(512).optional(),
endpointSuffix: z
.string()
.refine((v) => (AZURE_ENDPOINT_SUFFIXES as readonly string[]).includes(v), {
message: `endpointSuffix must be one of: ${AZURE_ENDPOINT_SUFFIXES.join(', ')}`,
})
.optional(),
})
const azureBlobCredentialsBodySchema = z.object({
accountKey: z
.string()
.length(88, 'accountKey must be 88 base64 characters (64-byte Azure storage key)')
.regex(/^[A-Za-z0-9+/]+={0,2}$/, {
message: 'accountKey must be a base64-encoded Azure storage account key',
}),
})
const DATADOG_TAG_PAIR_RE = /^[A-Za-z][A-Za-z0-9_./-]*:[^,\s][^,]*$/
const datadogConfigBodySchema = z.object({
site: z.enum(['us1', 'us3', 'us5', 'eu1', 'ap1', 'ap2', 'gov']),
service: z.string().min(1).max(100).optional(),
tags: z
.string()
.min(1)
.max(1024)
.refine(
(v) =>
v
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0)
.every((t) => DATADOG_TAG_PAIR_RE.test(t)),
{ message: 'tags must be comma-separated key:value pairs' }
)
.optional(),
})
const datadogCredentialsBodySchema = z.object({
apiKey: z.string().min(1, 'apiKey is required'),
})
const bigqueryConfigBodySchema = z.object({
projectId: z
.string()
.min(6, 'projectId is required')
.max(94)
.refine((v) => BQ_PROJECT_ID_RE.test(v), {
message: 'projectId must match Google Cloud project ID rules',
}),
datasetId: z
.string()
.min(1, 'datasetId is required')
.refine((v) => BQ_DATASET_RE.test(v), {
message: 'datasetId may only contain letters, digits, and underscores (max 1024)',
}),
tableId: z
.string()
.min(1, 'tableId is required')
.refine((v) => BQ_TABLE_RE.test(v), {
message:
'tableId may contain Unicode letters, marks, numbers, connectors, dashes, and spaces (max 1024)',
})
.refine((v) => Buffer.byteLength(v, 'utf8') <= 1024, {
message: 'tableId must be at most 1024 bytes (UTF-8)',
}),
})
const bigqueryCredentialsBodySchema = z.object({
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
})
const snowflakeConfigBodySchema = z.object({
account: z
.string()
.min(3, 'account is required')
.max(256)
.refine((v) => SNOWFLAKE_ACCOUNT_ORG_RE.test(v) || SNOWFLAKE_ACCOUNT_LOCATOR_RE.test(v), {
message:
'account must be a Snowflake org-account identifier (orgname-accountname) or legacy locator (locator[.region[.cloud]])',
}),
user: z.string().min(1, 'user is required').regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'user must be a valid Snowflake identifier',
}),
warehouse: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'warehouse must be a valid Snowflake identifier',
}),
database: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'database must be a valid Snowflake identifier',
}),
schema: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'schema must be a valid Snowflake identifier',
}),
table: z.string().min(1).regex(SNOWFLAKE_IDENTIFIER_RE, {
message: 'table must be a valid Snowflake identifier',
}),
column: z
.string()
.min(1)
.regex(SNOWFLAKE_IDENTIFIER_RE, { message: 'column must be a valid Snowflake identifier' })
.optional(),
role: z
.string()
.min(1)
.regex(SNOWFLAKE_IDENTIFIER_RE, { message: 'role must be a valid Snowflake identifier' })
.optional(),
})
const snowflakeCredentialsBodySchema = z.object({
privateKey: z.string().min(1, 'privateKey is required'),
})
const webhookConfigBodySchema = z.object({
url: z
.string()
.url('url must be a valid URL')
.max(2048, 'url must be at most 2048 characters')
.refine((value) => validateExternalUrl(value, 'url').isValid, {
message: 'url must be HTTPS and not point at a private, loopback, or metadata address',
}),
signatureHeader: z
.string()
.min(1)
.max(128)
.refine((value) => !RESERVED_WEBHOOK_SIGNATURE_HEADER_NAMES.has(value.toLowerCase()), {
message: 'signatureHeader cannot reuse a reserved Sim header name',
})
.refine((value) => /^[A-Za-z0-9\-_]+$/.test(value) && !/[\r\n\0]/.test(value), {
message: 'signatureHeader must contain only letters, digits, hyphens, and underscores',
})
.optional(),
})
const webhookCredentialsBodySchema = z.object({
signingSecret: z
.string()
.min(32, 'signingSecret must be at least 32 characters')
.max(512, 'signingSecret must be at most 512 characters'),
bearerToken: z
.string()
.min(1)
.max(4096, 'bearerToken must be at most 4096 characters')
.refine((value) => !/[\r\n\0]/.test(value), {
message: 'bearerToken cannot contain CR, LF, or NUL characters',
})
.optional(),
})
/**
* Discriminated body shape used by both create and update. Each destination
* variant carries its own typed `destinationConfig` and optional
* `destinationCredentials`. On update, omitting `destinationCredentials`
* leaves the encrypted blob in place.
*/
export const dataDrainDestinationBodySchema = z.discriminatedUnion('destinationType', [
z.object({
destinationType: z.literal('s3'),
destinationConfig: s3ConfigBodySchema,
destinationCredentials: s3CredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('gcs'),
destinationConfig: gcsConfigBodySchema,
destinationCredentials: gcsCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('azure_blob'),
destinationConfig: azureBlobConfigBodySchema,
destinationCredentials: azureBlobCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('datadog'),
destinationConfig: datadogConfigBodySchema,
destinationCredentials: datadogCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('bigquery'),
destinationConfig: bigqueryConfigBodySchema,
destinationCredentials: bigqueryCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('snowflake'),
destinationConfig: snowflakeConfigBodySchema,
destinationCredentials: snowflakeCredentialsBodySchema.optional(),
}),
z.object({
destinationType: z.literal('webhook'),
destinationConfig: webhookConfigBodySchema,
destinationCredentials: webhookCredentialsBodySchema.optional(),
}),
])
const drainCommonBodyFieldsSchema = z.object({
name: drainNameSchema,
source: dataDrainSourceSchema,
scheduleCadence: dataDrainCadenceSchema,
enabled: z.boolean().optional(),
})
export const createDataDrainBodySchema = z.intersection(
drainCommonBodyFieldsSchema,
dataDrainDestinationBodySchema
)
/**
* Update bodies are partial — every field is optional. We deliberately don't
* use a discriminated union here: clients sending `{ enabled: false }` should
* not be forced to also send `destinationType`. The route validates the
* destination payloads against the typed `configSchema` / `credentialsSchema`
* for the existing drain's destination type before persisting, so the
* structural shape is still enforced — just at the route layer rather than at
* the contract boundary.
*/
export const updateDataDrainBodySchema = drainCommonBodyFieldsSchema.partial().extend({
destinationType: dataDrainDestinationTypeSchema.optional(),
destinationConfig: z.record(z.string(), z.unknown()).optional(),
destinationCredentials: z.record(z.string(), z.unknown()).optional(),
})
const drainDestinationResponseSchema = z.discriminatedUnion('destinationType', [
z.object({
destinationType: z.literal('s3'),
destinationConfig: s3ConfigBodySchema,
}),
z.object({
destinationType: z.literal('gcs'),
destinationConfig: gcsConfigBodySchema,
}),
z.object({
destinationType: z.literal('azure_blob'),
destinationConfig: azureBlobConfigBodySchema,
}),
z.object({
destinationType: z.literal('datadog'),
destinationConfig: datadogConfigBodySchema,
}),
z.object({
destinationType: z.literal('bigquery'),
destinationConfig: bigqueryConfigBodySchema,
}),
z.object({
destinationType: z.literal('snowflake'),
destinationConfig: snowflakeConfigBodySchema,
}),
z.object({
destinationType: z.literal('webhook'),
destinationConfig: webhookConfigBodySchema,
}),
])
const drainCommonResponseFieldsSchema = z.object({
id: z.string(),
organizationId: z.string(),
name: z.string(),
source: dataDrainSourceSchema,
scheduleCadence: dataDrainCadenceSchema,
enabled: z.boolean(),
cursor: z.string().nullable(),
lastRunAt: z.string().nullable(),
lastSuccessAt: z.string().nullable(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const dataDrainSchema = z.intersection(
drainCommonResponseFieldsSchema,
drainDestinationResponseSchema
)
export type DataDrain = z.output<typeof dataDrainSchema>
export type CreateDataDrainBody = z.input<typeof createDataDrainBodySchema>
export type UpdateDataDrainBody = z.input<typeof updateDataDrainBodySchema>
export const dataDrainListResponseSchema = z.object({
drains: z.array(dataDrainSchema),
})
export const dataDrainResponseSchema = z.object({
drain: dataDrainSchema,
})
export const dataDrainRunSchema = z.object({
id: z.string(),
drainId: z.string(),
status: dataDrainRunStatusSchema,
trigger: dataDrainRunTriggerSchema,
startedAt: z.string(),
finishedAt: z.string().nullable(),
rowsExported: z.number().int(),
bytesWritten: z.number().int(),
cursorBefore: z.string().nullable(),
cursorAfter: z.string().nullable(),
error: z.string().nullable(),
locators: z.array(z.string()),
})
export type DataDrainRun = z.output<typeof dataDrainRunSchema>
export const dataDrainRunListResponseSchema = z.object({
runs: z.array(dataDrainRunSchema),
})
export const runDataDrainResponseSchema = z.object({
jobId: z.string(),
})
export const testDataDrainResponseSchema = z.object({
ok: z.literal(true),
})
export const listDataDrainsContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/data-drains',
params: dataDrainOrgParamsSchema,
response: { mode: 'json', schema: dataDrainListResponseSchema },
})
export const createDataDrainContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/data-drains',
params: dataDrainOrgParamsSchema,
body: createDataDrainBodySchema,
response: { mode: 'json', schema: dataDrainResponseSchema },
})
export const getDataDrainContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/data-drains/[drainId]',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: dataDrainResponseSchema },
})
export const updateDataDrainContract = defineRouteContract({
method: 'PUT',
path: '/api/organizations/[id]/data-drains/[drainId]',
params: dataDrainParamsSchema,
body: updateDataDrainBodySchema,
response: { mode: 'json', schema: dataDrainResponseSchema },
})
export const deleteDataDrainContract = defineRouteContract({
method: 'DELETE',
path: '/api/organizations/[id]/data-drains/[drainId]',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: z.object({ success: z.literal(true) }) },
})
export const runDataDrainContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/data-drains/[drainId]/run',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: runDataDrainResponseSchema },
})
export const testDataDrainContract = defineRouteContract({
method: 'POST',
path: '/api/organizations/[id]/data-drains/[drainId]/test',
params: dataDrainParamsSchema,
response: { mode: 'json', schema: testDataDrainResponseSchema },
})
export const listDataDrainRunsContract = defineRouteContract({
method: 'GET',
path: '/api/organizations/[id]/data-drains/[drainId]/runs',
params: dataDrainParamsSchema,
query: z
.object({
limit: z
.preprocess(
(v) => (typeof v === 'string' ? Number.parseInt(v, 10) : v),
z.number().int().min(1).max(200)
)
.optional(),
})
.optional(),
response: { mode: 'json', schema: dataDrainRunListResponseSchema },
})