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
@@ -0,0 +1,186 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockUpload, mockDeleteIfExists, BlobServiceClientCtor, StorageSharedKeyCredentialCtor } =
vi.hoisted(() => {
const mockUpload = vi.fn(async () => ({}))
const mockDeleteIfExists = vi.fn(async () => ({ succeeded: true }))
const blockBlobClient = { upload: mockUpload, deleteIfExists: mockDeleteIfExists }
const containerClient = { getBlockBlobClient: vi.fn(() => blockBlobClient) }
return {
mockUpload,
mockDeleteIfExists,
BlobServiceClientCtor: vi.fn().mockImplementation(
class {
getContainerClient = vi.fn(() => containerClient)
}
),
StorageSharedKeyCredentialCtor: vi.fn().mockImplementation(class {}),
}
})
vi.mock('@azure/storage-blob', () => ({
BlobServiceClient: BlobServiceClientCtor,
StorageSharedKeyCredential: StorageSharedKeyCredentialCtor,
}))
import { azureBlobDestination } from '@/lib/data-drains/destinations/azure_blob'
const config = { accountName: 'simstore', containerName: 'drains', prefix: 'sim/' }
// Realistic 88-char base64 (64-byte) Azure storage key shape.
const credentials = {
accountKey:
'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('azureBlobDestination openSession', () => {
it('uploads via BlockBlobClient and returns an azure:// locator', async () => {
const session = azureBlobDestination.openSession({ config, credentials })
const body = Buffer.from('row\n', 'utf8')
const result = await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd1',
runId: 'r1',
source: 'workflow_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
expect(result.locator).toMatch(
/^azure:\/\/simstore\/drains\/sim\/workflow_logs\/d1\/\d{4}\/\d{2}\/\d{2}\/r1-00000\.ndjson$/
)
expect(mockUpload).toHaveBeenCalledTimes(1)
const [calledBody, calledLength, opts] = mockUpload.mock.calls[0] as [
Buffer,
number,
{ metadata?: Record<string, string> },
]
expect(calledBody).toBe(body)
expect(calledLength).toBe(body.byteLength)
expect(opts.metadata?.simdrainid).toBe('d1')
expect(opts.metadata?.simsequence).toBe('0')
const fullOpts = opts as {
blobHTTPHeaders?: { blobContentType?: string }
abortSignal?: AbortSignal
}
expect(fullOpts.blobHTTPHeaders?.blobContentType).toBe('application/x-ndjson')
expect(fullOpts.abortSignal).toBeDefined()
expect(StorageSharedKeyCredentialCtor).toHaveBeenCalledWith('simstore', credentials.accountKey)
expect(BlobServiceClientCtor).toHaveBeenCalledWith(
'https://simstore.blob.core.windows.net',
expect.anything(),
expect.objectContaining({ retryOptions: expect.any(Object) })
)
await session.close()
})
it('routes to a sovereign-cloud endpoint suffix when configured', async () => {
const session = azureBlobDestination.openSession({
config: { ...config, endpointSuffix: 'blob.core.usgovcloudapi.net' },
credentials,
})
await session.deliver({
body: Buffer.from('row\n', 'utf8'),
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd',
runId: 'r',
source: 'workflow_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
expect(BlobServiceClientCtor).toHaveBeenCalledWith(
'https://simstore.blob.core.usgovcloudapi.net',
expect.anything(),
expect.objectContaining({ retryOptions: expect.any(Object) })
)
await session.close()
})
it('surfaces Azure REST errors', async () => {
mockUpload.mockRejectedValueOnce(
Object.assign(new Error('Forbidden'), {
statusCode: 403,
code: 'AuthenticationFailed',
})
)
const session = azureBlobDestination.openSession({ config, credentials })
await expect(
session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd',
runId: 'r',
source: 'audit_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
).rejects.toThrow(/AuthenticationFailed 403/)
await session.close()
})
})
describe('azureBlobDestination test()', () => {
it('writes a probe blob then attempts cleanup', async () => {
await azureBlobDestination.test!({
config,
credentials,
signal: new AbortController().signal,
})
expect(mockUpload).toHaveBeenCalled()
expect(mockDeleteIfExists).toHaveBeenCalled()
})
})
describe('azureBlobDestination config schema', () => {
it('rejects invalid account names', () => {
const result = azureBlobDestination.configSchema.safeParse({
accountName: 'BAD-NAME',
containerName: 'drains',
})
expect(result.success).toBe(false)
})
it('rejects invalid container names', () => {
const result = azureBlobDestination.configSchema.safeParse({
accountName: 'simstore',
containerName: '--bad--',
})
expect(result.success).toBe(false)
})
})
describe('azureBlobDestination credentials schema', () => {
it('rejects non-base64 account keys', () => {
const padded = 'a'.repeat(70)
const result = azureBlobDestination.credentialsSchema.safeParse({
accountKey: `${padded}!@#$`,
})
expect(result.success).toBe(false)
})
it('rejects keys that are too short', () => {
const result = azureBlobDestination.credentialsSchema.safeParse({ accountKey: 'YQ==' })
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,203 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { z } from 'zod'
import { buildObjectKey, normalizePrefix } from '@/lib/data-drains/destinations/utils'
import type { DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainAzureBlobDestination')
/**
* Azure storage account names: 3-24 chars, lowercase letters and digits only.
* https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview#storage-account-name
*/
const ACCOUNT_NAME_RE = /^[a-z0-9]{3,24}$/
/**
* Azure container names: 3-63 chars, lowercase letters, digits, single hyphens
* (no leading/trailing/double hyphens).
* https://learn.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata
*/
const CONTAINER_NAME_RE = /^[a-z0-9]([a-z0-9]|-(?!-))+[a-z0-9]$/
/** Azure storage account keys are 64 raw bytes => exactly 88 base64 chars (0-2 trailing `=`). */
const ACCOUNT_KEY_RE = /^[A-Za-z0-9+/]+={0,2}$/
/** Public cloud default; sovereign clouds (Gov/China/legacy DE) are validated via allowlist. */
const DEFAULT_ENDPOINT_SUFFIX = 'blob.core.windows.net'
/**
* Allowlist of Azure Storage endpoint suffixes. URL host must end with one of these
* (after the account name + dot). Reject anything else to prevent SSRF via attacker-controlled
* endpoint suffix.
*/
const ALLOWED_ENDPOINT_SUFFIXES = [
'blob.core.windows.net',
'blob.core.usgovcloudapi.net',
'blob.core.chinacloudapi.cn',
'blob.core.cloudapi.de',
] as const
const azureBlobConfigSchema = z.object({
accountName: z
.string()
.min(1, 'accountName is required')
.refine((value) => ACCOUNT_NAME_RE.test(value), {
message: 'accountName must be 3-24 lowercase letters or digits',
}),
containerName: z
.string()
.min(3, 'containerName must be 3-63 characters')
.max(63)
.refine((value) => CONTAINER_NAME_RE.test(value), {
message: 'containerName must use lowercase letters, digits, or single hyphens',
}),
/** Optional prefix; trailing slash is added automatically when assembling blob names. */
prefix: z.string().max(512).optional(),
/** Storage endpoint suffix. Must be one of the known Azure Storage suffixes (public/Gov/China/DE). */
endpointSuffix: z
.string()
.refine((v) => (ALLOWED_ENDPOINT_SUFFIXES as readonly string[]).includes(v), {
message: `endpointSuffix must be one of: ${ALLOWED_ENDPOINT_SUFFIXES.join(', ')}`,
})
.optional(),
})
const azureBlobCredentialsSchema = z.object({
accountKey: z
.string()
.length(88, 'accountKey must be exactly 88 base64 characters (64-byte Azure storage key)')
.refine((v) => ACCOUNT_KEY_RE.test(v), {
message: 'accountKey must be a base64-encoded Azure storage account key',
}),
})
export type AzureBlobDestinationConfig = z.infer<typeof azureBlobConfigSchema>
export type AzureBlobDestinationCredentials = z.infer<typeof azureBlobCredentialsSchema>
interface BlobClients {
containerClient: import('@azure/storage-blob').ContainerClient
}
async function buildClients(
config: AzureBlobDestinationConfig,
credentials: AzureBlobDestinationCredentials
): Promise<BlobClients> {
const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob')
const sharedKeyCredential = new StorageSharedKeyCredential(
config.accountName,
credentials.accountKey
)
const suffix = config.endpointSuffix ?? DEFAULT_ENDPOINT_SUFFIX
/**
* Bound per-attempt timeout. SDK default `tryTimeoutInMs` is "infinite" / OS
* connection-idle limits, so a hung receiver could pin a delivery indefinitely.
* 30s per try + 5 tries (~ exponential 0.5s → 30s) caps the worst-case wall time.
*/
const blobServiceClient = new BlobServiceClient(
`https://${config.accountName}.${suffix}`,
sharedKeyCredential,
{
retryOptions: {
tryTimeoutInMs: 30_000,
maxTries: 5,
retryDelayInMs: 500,
maxRetryDelayInMs: 30_000,
},
}
)
return { containerClient: blobServiceClient.getContainerClient(config.containerName) }
}
interface AzureRestErrorLike {
statusCode?: number
code?: string
message?: string
}
function isAzureRestError(error: unknown): error is AzureRestErrorLike {
return typeof error === 'object' && error !== null && ('statusCode' in error || 'code' in error)
}
async function withAzureErrorContext<T>(action: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (error) {
if (isAzureRestError(error)) {
const status = error.statusCode
const code = error.code
logger.warn('Azure Blob operation failed', { action, code, status })
throw new Error(
`Azure Blob ${action} failed (${code ?? 'Error'}${status ? ` ${status}` : ''}): ${error.message ?? ''}`,
{ cause: error }
)
}
throw error
}
}
export const azureBlobDestination: DrainDestination<
AzureBlobDestinationConfig,
AzureBlobDestinationCredentials
> = {
type: 'azure_blob',
displayName: 'Azure Blob Storage',
configSchema: azureBlobConfigSchema,
credentialsSchema: azureBlobCredentialsSchema,
async test({ config, credentials, signal }) {
const { containerClient } = await buildClients(config, credentials)
const probeName = `${normalizePrefix(config.prefix)}.sim-drain-write-probe/${generateShortId(12)}`
const blockBlobClient = containerClient.getBlockBlobClient(probeName)
await withAzureErrorContext('test-put', () =>
blockBlobClient.upload(Buffer.alloc(0), 0, {
blobHTTPHeaders: { blobContentType: 'application/octet-stream' },
abortSignal: signal,
})
)
try {
await blockBlobClient.deleteIfExists({ abortSignal: signal })
} catch (cleanupError) {
logger.debug('Azure Blob test write probe cleanup failed (non-fatal)', {
accountName: config.accountName,
containerName: config.containerName,
blobName: probeName,
error: cleanupError,
})
}
},
openSession({ config, credentials }) {
let clientsPromise: Promise<BlobClients> | null = null
return {
async deliver({ body, contentType, metadata, signal }) {
if (clientsPromise === null) clientsPromise = buildClients(config, credentials)
const { containerClient } = await clientsPromise
const blobName = buildObjectKey(config.prefix, metadata)
const blockBlobClient = containerClient.getBlockBlobClient(blobName)
await withAzureErrorContext('put-object', () =>
blockBlobClient.upload(body, body.byteLength, {
blobHTTPHeaders: { blobContentType: contentType },
metadata: {
simdrainid: metadata.drainId,
simrunid: metadata.runId,
simsource: metadata.source,
simsequence: metadata.sequence.toString(),
simrowcount: metadata.rowCount.toString(),
},
abortSignal: signal,
})
)
logger.debug('Azure Blob chunk delivered', {
accountName: config.accountName,
containerName: config.containerName,
blobName,
bytes: body.byteLength,
})
return {
locator: `azure://${config.accountName}/${config.containerName}/${blobName}`,
}
},
async close() {},
}
},
}
@@ -0,0 +1,276 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetAccessToken, JWTCtor, loggerInstance } = vi.hoisted(() => {
const mockGetAccessToken = vi.fn(async () => ({ token: 'bq-token' }))
const loggerInstance = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
withMetadata: vi.fn(),
}
return {
mockGetAccessToken,
JWTCtor: vi.fn().mockImplementation(
class {
getAccessToken = mockGetAccessToken
}
),
loggerInstance,
}
})
vi.mock('google-auth-library', () => ({ JWT: JWTCtor }))
vi.mock('@sim/logger', () => ({
createLogger: () => loggerInstance,
logger: loggerInstance,
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
getRequestContext: () => undefined,
}))
vi.mock('@sim/utils/helpers', () => ({
sleep: vi.fn(async () => {}),
}))
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
vi.stubGlobal('fetch', fetchMock)
import { bigqueryDestination } from '@/lib/data-drains/destinations/bigquery'
const config = { projectId: 'my-proj', datasetId: 'logs', tableId: 'workflow' }
const credentials = {
serviceAccountJson: JSON.stringify({
client_email: 'sa@p.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n',
}),
}
const meta = {
drainId: 'd',
runId: 'r',
source: 'workflow_logs' as const,
sequence: 0,
rowCount: 2,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
}
beforeEach(() => {
vi.clearAllMocks()
fetchMock.mockResolvedValue(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
mockGetAccessToken.mockResolvedValue({ token: 'bq-token' })
})
describe('bigqueryDestination', () => {
it('posts rows with stable insertIds for dedup', async () => {
const session = bigqueryDestination.openSession({ config, credentials })
const body = Buffer.from(
`${JSON.stringify({ id: 'a' })}\n${JSON.stringify({ id: 'b' })}\n`,
'utf8'
)
const result = await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/projects/my-proj/datasets/logs/tables/workflow/insertAll')
const payload = JSON.parse(init.body as string)
expect(payload.rows).toHaveLength(2)
expect(payload.rows[0].insertId).toBe('d-r-0-0')
expect(payload.rows[0].json).toEqual({ id: 'a' })
expect(payload.rows[1].insertId).toBe('d-r-0-1')
expect(result.locator).toBe('bigquery://my-proj/logs/workflow#r-0')
await session.close()
})
it('throws with row indices and warns on partial-failure insertErrors', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
insertErrors: [
{ index: 1, errors: [{ message: 'invalid', reason: 'invalid' }] },
{ index: 2, errors: [{ message: 'bad', reason: 'invalid' }] },
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
const session = bigqueryDestination.openSession({ config, credentials })
const body = Buffer.from(
`${JSON.stringify({ x: 1 })}\n${JSON.stringify({ x: 2 })}\n${JSON.stringify({ x: 3 })}\n`
)
await expect(
session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
).rejects.toThrow(/partial failure.*1,2.*dedup-keyed by insertId/s)
expect(loggerInstance.warn).toHaveBeenCalledWith(
expect.stringContaining('partial failure'),
expect.objectContaining({
partialFailure: true,
succeededRows: 1,
failedRows: 2,
})
)
await session.close()
})
it('test() probes table existence with a GET', async () => {
await bigqueryDestination.test!({
config,
credentials,
signal: new AbortController().signal,
})
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('?fields=id')
expect(init.method).toBeUndefined()
})
it('throws a clear error when an NDJSON line is malformed', async () => {
const session = bigqueryDestination.openSession({ config, credentials })
const body = Buffer.from(`${JSON.stringify({ id: 'a' })}\n{not json}\n`, 'utf8')
await expect(
session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
).rejects.toThrow(/NDJSON parse failed at line 2/)
expect(fetchMock).not.toHaveBeenCalled()
await session.close()
})
it('throws when an NDJSON row is not a JSON object', async () => {
const session = bigqueryDestination.openSession({ config, credentials })
const body = Buffer.from(`${JSON.stringify({ id: 'a' })}\n42\n`, 'utf8')
await expect(
session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
).rejects.toThrow(/NDJSON row at line 2 is not an object/)
await session.close()
})
it('parses NDJSON with CRLF line endings', async () => {
const session = bigqueryDestination.openSession({ config, credentials })
const body = Buffer.from(
`${JSON.stringify({ id: 'a' })}\r\n${JSON.stringify({ id: 'b' })}\r\n`,
'utf8'
)
await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const payload = JSON.parse(init.body as string)
expect(payload.rows).toHaveLength(2)
expect(payload.rows[0].json).toEqual({ id: 'a' })
expect(payload.rows[1].json).toEqual({ id: 'b' })
await session.close()
})
it('insertId includes drainId prefix to avoid cross-drain collisions', async () => {
const session = bigqueryDestination.openSession({ config, credentials })
const body = Buffer.from(`${JSON.stringify({ id: 'a' })}\n`, 'utf8')
await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: { ...meta, drainId: 'drain-xyz', runId: 'run-1', sequence: 7 },
signal: new AbortController().signal,
})
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const payload = JSON.parse(init.body as string)
expect(payload.rows[0].insertId).toBe('drain-xyz-run-1-7-0')
await session.close()
})
it('retries 5xx responses with backoff, then succeeds', async () => {
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 503 })).mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
const session = bigqueryDestination.openSession({ config, credentials })
await session.deliver({
body: Buffer.from(`${JSON.stringify({ id: 'a' })}\n`),
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(loggerInstance.warn).toHaveBeenCalledWith(
expect.stringContaining('transient error'),
expect.objectContaining({ status: 503, attempt: 1 })
)
await session.close()
})
it('accepts domain-scoped project IDs', () => {
const result = bigqueryDestination.configSchema.safeParse({
projectId: 'example.com:my-project',
datasetId: 'logs',
tableId: 'workflow',
})
expect(result.success).toBe(true)
const standard = bigqueryDestination.configSchema.safeParse({
projectId: 'my-proj',
datasetId: 'logs',
tableId: 'workflow',
})
expect(standard.success).toBe(true)
})
it('test() throws when serviceAccountJson is missing required fields', async () => {
await expect(
bigqueryDestination.test!({
config,
credentials: {
serviceAccountJson: JSON.stringify({
client_email: 'sa@p.iam.gserviceaccount.com',
}),
},
signal: new AbortController().signal,
})
).rejects.toThrow(/missing private_key/)
await expect(
bigqueryDestination.test!({
config,
credentials: {
serviceAccountJson: JSON.stringify({
private_key: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n',
}),
},
signal: new AbortController().signal,
})
).rejects.toThrow(/missing client_email/)
})
})
@@ -0,0 +1,333 @@
import { createHash } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { JWT } from 'google-auth-library'
import { z } from 'zod'
import {
type ParsedServiceAccount,
parseNdjsonObjects,
parseServiceAccount,
refineServiceAccountJson,
sleepUntilAborted,
} from '@/lib/data-drains/destinations/utils'
import type { DeliveryMetadata, DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainBigQueryDestination')
/**
* Uses the legacy `tabledata.insertAll` streaming endpoint. The Storage Write
* API offers exactly-once semantics and lower pricing but requires gRPC; we
* stay on insertAll for simplicity and direct HTTP support.
*/
/** `insertdata` for streaming inserts; `readonly` for the `tables.get` probe in `test()`. */
const SCOPES = [
'https://www.googleapis.com/auth/bigquery.insertdata',
'https://www.googleapis.com/auth/bigquery.readonly',
]
/** Standard project IDs are 6-30 chars; the optional `domain.tld:` prefix supports legacy domain-scoped projects. */
const PROJECT_ID_RE = /^([a-z][a-z0-9.-]{0,61}[a-z0-9]:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const DATASET_RE = /^[A-Za-z0-9_]{1,1024}$/
const TABLE_RE = /^[\p{L}\p{M}\p{N}\p{Pc}\p{Pd} ]{1,1024}$/u
const USER_AGENT = 'sim-data-drain/1.0'
/** Per-request streaming limits: 10 MB body, 50,000 rows, 1 MB per row. */
const MAX_REQUEST_BYTES = 10 * 1024 * 1024
const MAX_ROWS_PER_REQUEST = 50_000
const MAX_ROW_BYTES = 1024 * 1024
/** `insertId` is capped at 128 characters (encoded length). */
const MAX_INSERT_ID_LENGTH = 128
const PER_ATTEMPT_TIMEOUT_MS = 60_000
const bigqueryConfigSchema = z.object({
projectId: z
.string()
.min(6, 'projectId is required')
.refine((v) => PROJECT_ID_RE.test(v), {
message: 'projectId must match Google Cloud project ID rules',
}),
datasetId: z
.string()
.min(1, 'datasetId is required')
.refine((v) => DATASET_RE.test(v), {
message: 'datasetId may only contain ASCII letters, digits, and underscores (max 1024 chars)',
}),
tableId: z
.string()
.min(1, 'tableId is required')
.refine((v) => TABLE_RE.test(v), {
message:
'tableId may only contain Unicode letters/marks/numbers, connectors, dashes, and spaces (max 1024 chars)',
})
.refine((v) => Buffer.byteLength(v, 'utf8') <= 1024, {
message: 'tableId must be at most 1024 bytes when UTF-8 encoded',
}),
})
const bigqueryCredentialsSchema = z
.object({
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
})
.superRefine(refineServiceAccountJson)
export type BigQueryDestinationConfig = z.infer<typeof bigqueryConfigSchema>
export type BigQueryDestinationCredentials = z.infer<typeof bigqueryCredentialsSchema>
function buildJwt(account: ParsedServiceAccount): JWT {
return new JWT({ email: account.clientEmail, key: account.privateKey, scopes: SCOPES })
}
async function getAccessToken(jwt: JWT, forceRefresh = false): Promise<string> {
if (forceRefresh) {
/** Clearing `credentials` forces `getAccessToken` to mint a new token instead of returning the cached one. */
jwt.credentials = {}
}
const { token } = await jwt.getAccessToken()
if (!token) throw new Error('Failed to obtain BigQuery access token')
return token
}
interface InsertAllInput {
config: BigQueryDestinationConfig
rows: Record<string, unknown>[]
metadata: DeliveryMetadata
jwt: JWT
signal: AbortSignal
}
interface InsertAllError {
index: number
errors: Array<{ reason?: string; message?: string; location?: string }>
}
async function postInsertAll(
input: InsertAllInput,
url: string,
body: string,
forceRefresh = false
): Promise<Response> {
const token = await getAccessToken(input.jwt, forceRefresh)
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
try {
return await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
},
body,
signal: perAttempt,
})
} catch (error) {
logger.warn('BigQuery request failed', {
table: `${input.config.projectId}.${input.config.datasetId}.${input.config.tableId}`,
error: toError(error).message,
})
throw error
}
}
/**
* Builds a stable `insertId` for best-effort dedup (~60s window). Prefixed
* with `drainId` so (runId, sequence) collisions across drains do not
* accidentally dedupe each other's rows. With UUID drain/run IDs the raw
* form fits well under 128 chars; if anything pushes it over (e.g. a future
* non-UUID id), hash the prefix and keep the row-distinguishing `index`
* suffix intact so BigQuery does not silently dedupe distinct rows.
*/
function buildInsertId(metadata: DeliveryMetadata, index: number): string {
const raw = `${metadata.drainId}-${metadata.runId}-${metadata.sequence}-${index}`
if (raw.length <= MAX_INSERT_ID_LENGTH) return raw
const prefixHash = createHash('sha1')
.update(`${metadata.drainId}-${metadata.runId}-${metadata.sequence}`)
.digest('hex')
return `${prefixHash}-${index}`
}
const RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504])
const MAX_RETRY_ATTEMPTS = 3
const BASE_RETRY_DELAY_MS = 250
/**
* Streams a chunk of rows to `tabledata.insertAll`.
*
* Partial-success caveat: BigQuery may return HTTP 200 with a non-empty
* `insertErrors` array. Rows not listed there are inserted and dedup-keyed by
* `insertId` for ~60s. We throw on any `insertErrors`; retries within the
* dedup window are safe, but retries after it may duplicate succeeded rows.
*/
async function insertAll(input: InsertAllInput): Promise<void> {
if (input.rows.length > MAX_ROWS_PER_REQUEST) {
throw new Error(
`BigQuery insertAll chunk has ${input.rows.length} rows, exceeds the ${MAX_ROWS_PER_REQUEST} per-request limit`
)
}
const url = `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(input.config.projectId)}/datasets/${encodeURIComponent(input.config.datasetId)}/tables/${encodeURIComponent(input.config.tableId)}/insertAll`
/**
* `skipInvalidRows: false` and `ignoreUnknownValues: false` surface schema
* mismatches as `insertErrors` instead of silently dropping data — drains
* should fail loudly so operators notice the schema drift.
*/
const payload = {
skipInvalidRows: false,
ignoreUnknownValues: false,
rows: input.rows.map((row, index) => {
const rowBytes = Buffer.byteLength(JSON.stringify(row), 'utf8')
if (rowBytes > MAX_ROW_BYTES) {
throw new Error(
`BigQuery row at index ${index} is ${rowBytes} bytes, exceeds the ${MAX_ROW_BYTES}-byte per-row limit`
)
}
return {
insertId: buildInsertId(input.metadata, index),
json: row,
}
}),
}
const body = JSON.stringify(payload)
const byteLength = Buffer.byteLength(body, 'utf8')
if (byteLength > MAX_REQUEST_BYTES) {
throw new Error(
`BigQuery insertAll body is ${byteLength} bytes, exceeds the ${MAX_REQUEST_BYTES}-byte per-request limit`
)
}
let attempt = 0
let response: Response | undefined
let refreshedOnce = false
while (true) {
attempt++
try {
response = await postInsertAll(input, url, body)
/** A 401 retry doesn't count against the 5xx/429 budget — token refresh is a one-shot recovery. */
if (response.status === 401 && !refreshedOnce) {
refreshedOnce = true
logger.debug('BigQuery returned 401; refreshing access token and retrying once')
/** Drain the 401 body before discarding so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
response = await postInsertAll(input, url, body, true)
}
if (!RETRYABLE_STATUSES.has(response.status)) break
if (attempt >= MAX_RETRY_ATTEMPTS) break
const retryAfterHeaderMs = parseRetryAfter(response.headers.get('retry-after'))
const retryAfterMs = backoffWithJitter(attempt, retryAfterHeaderMs, {
baseMs: BASE_RETRY_DELAY_MS,
})
logger.warn('BigQuery insertAll transient error; retrying', {
status: response.status,
attempt,
retryAfterMs,
})
/** Drain the body so the keep-alive connection can be reused. */
await response.text().catch(() => '')
await sleepUntilAborted(retryAfterMs, input.signal)
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
} catch (error) {
/**
* Connection-level failures (DNS, socket reset, timeout) never produce
* a Response — treat them like 5xx and retry with backoff. Re-throw
* aborts unwrapped so callers see the cancellation reason.
*/
if (input.signal.aborted) throw input.signal.reason ?? error
if (attempt >= MAX_RETRY_ATTEMPTS) throw error
const retryAfterMs = backoffWithJitter(attempt, null, { baseMs: BASE_RETRY_DELAY_MS })
logger.warn('BigQuery insertAll network error; retrying', {
attempt,
retryAfterMs,
error: toError(error).message,
})
await sleepUntilAborted(retryAfterMs, input.signal)
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
}
}
if (!response) throw new Error('BigQuery insertAll failed: no response')
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`BigQuery insertAll failed (HTTP ${response.status}): ${text}`)
}
const result = (await response.json().catch(() => ({}))) as {
insertErrors?: InsertAllError[]
}
if (result.insertErrors && result.insertErrors.length > 0) {
const failedIndices = result.insertErrors.map((e) => e.index)
const total = input.rows.length
const failed = result.insertErrors.length
const succeeded = total - failed
logger.warn('BigQuery insertAll returned partial failure', {
partialFailure: true,
table: `${input.config.projectId}.${input.config.datasetId}.${input.config.tableId}`,
succeededRows: succeeded,
failedRows: failed,
failedIndices: failedIndices.slice(0, 20),
})
const summary = result.insertErrors
.slice(0, 3)
.map(
(e) =>
`row ${e.index}: ${e.errors.map((er) => er.message ?? er.reason ?? 'unknown').join('; ')}`
)
.join(' | ')
throw new Error(
`BigQuery insertAll partial failure: ${failed} of ${total} rows failed (indices: ${failedIndices.slice(0, 20).join(',')}${failedIndices.length > 20 ? ',...' : ''}); ${succeeded} rows were inserted and are dedup-keyed by insertId for ~60s — retries within that window are safe, but retries after the window may duplicate the succeeded rows. First errors: ${summary}`
)
}
}
export const bigqueryDestination: DrainDestination<
BigQueryDestinationConfig,
BigQueryDestinationCredentials
> = {
type: 'bigquery',
displayName: 'Google BigQuery',
configSchema: bigqueryConfigSchema,
credentialsSchema: bigqueryCredentialsSchema,
/**
* Probes table existence, IAM access, and credential validity in a single
* `tables.get` call. `fields=id` minimises response size — we only care
* whether the call succeeds, not the payload.
*/
async test({ config, credentials, signal }) {
const account = parseServiceAccount(credentials.serviceAccountJson)
const jwt = buildJwt(account)
const token = await getAccessToken(jwt)
const url = `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(config.projectId)}/datasets/${encodeURIComponent(config.datasetId)}/tables/${encodeURIComponent(config.tableId)}?fields=id`
const perAttempt = AbortSignal.any([signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: perAttempt,
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`BigQuery probe failed (HTTP ${response.status}): ${text}`)
}
/** Drain the success body so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
},
openSession({ config, credentials }) {
const account = parseServiceAccount(credentials.serviceAccountJson)
const jwt = buildJwt(account)
return {
async deliver({ body, metadata, signal }) {
const rows = parseNdjsonObjects(body, { requireObject: true }) as Record<string, unknown>[]
if (rows.length === 0) {
return {
locator: `bigquery://${config.projectId}/${config.datasetId}/${config.tableId}#${metadata.runId}-${metadata.sequence}`,
}
}
await insertAll({ config, rows, metadata, jwt, signal })
logger.debug('BigQuery chunk delivered', {
table: `${config.projectId}.${config.datasetId}.${config.tableId}`,
rows: rows.length,
})
return {
locator: `bigquery://${config.projectId}/${config.datasetId}/${config.tableId}#${metadata.runId}-${metadata.sequence}`,
}
},
async close() {},
}
},
}
@@ -0,0 +1,211 @@
/**
* @vitest-environment node
*/
import { gunzipSync } from 'node:zlib'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fetchMock = vi.fn(async () => new Response(null, { status: 202 }))
vi.stubGlobal('fetch', fetchMock)
import { datadogDestination } from '@/lib/data-drains/destinations/datadog'
const config = { site: 'us1' as const, service: 'sim', tags: 'env:prod' }
const credentials = { apiKey: 'dd-key' }
const meta = (sequence: number) => ({
drainId: 'd1',
runId: 'r1',
source: 'workflow_logs' as const,
sequence,
rowCount: 2,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
})
beforeEach(() => {
vi.clearAllMocks()
fetchMock.mockResolvedValue(new Response(null, { status: 202 }))
})
describe('datadogDestination', () => {
it('parses NDJSON and POSTs a JSON array of log entries', async () => {
const session = datadogDestination.openSession({ config, credentials })
const body = Buffer.from(
`${JSON.stringify({ id: 'a', name: 'one' })}\n${JSON.stringify({ id: 'b', name: 'two' })}\n`,
'utf8'
)
const result = await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta(0),
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://http-intake.logs.datadoghq.com/api/v2/logs')
const headers = init.headers as Record<string, string>
expect(headers['DD-API-KEY']).toBe('dd-key')
expect(headers['Content-Type']).toBe('application/json')
expect(headers.Accept).toBe('application/json')
expect(headers['User-Agent']).toBe('sim-data-drain/1.0')
expect(headers['Content-Encoding']).toBeUndefined()
const payload = JSON.parse(init.body as string)
expect(payload).toHaveLength(2)
expect(payload[0].ddsource).toBe('sim')
expect(payload[0].service).toBe('sim')
expect(payload[0].ddtags).toContain('sim_drain_id:d1')
expect(payload[0].ddtags).toContain('env:prod')
expect(payload[0].id).toBe('a')
expect(payload[0].name).toBe('one')
expect(payload[0].attributes).toBeUndefined()
expect(result.locator).toMatch(/^datadog:\/\/us1#r1-0/)
await session.close()
})
it('retries 5xx responses then surfaces the final error', async () => {
vi.useFakeTimers()
try {
fetchMock.mockResolvedValue(new Response('boom', { status: 503 }))
const session = datadogDestination.openSession({ config, credentials })
const promise = session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta(0),
signal: new AbortController().signal,
})
// Attach a handler so Node doesn't flag the in-flight rejection while
// we advance fake timers; we still assert via the original promise below.
const settled = promise.catch((e) => e)
await vi.runAllTimersAsync()
await expect(settled).resolves.toMatchObject({ message: expect.stringMatching(/HTTP 503/) })
expect(fetchMock).toHaveBeenCalledTimes(4)
await session.close()
} finally {
vi.useRealTimers()
}
})
it('does not retry on non-retryable 4xx (e.g. invalid API key)', async () => {
fetchMock.mockResolvedValueOnce(new Response('forbidden', { status: 403 }))
const session = datadogDestination.openSession({ config, credentials })
await expect(
session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta(0),
signal: new AbortController().signal,
})
).rejects.toThrow(/HTTP 403/)
expect(fetchMock).toHaveBeenCalledTimes(1)
await session.close()
})
it('routes to the EU site host', async () => {
const session = datadogDestination.openSession({
config: { ...config, site: 'eu1' },
credentials,
})
await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta(0),
signal: new AbortController().signal,
})
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://http-intake.logs.datadoghq.eu/api/v2/logs')
await session.close()
})
it('routes to the AP2 site host', async () => {
const session = datadogDestination.openSession({
config: { ...config, site: 'ap2' },
credentials,
})
await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta(0),
signal: new AbortController().signal,
})
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'https://http-intake.logs.ap2.datadoghq.com/api/v2/logs'
)
await session.close()
})
it('throws with the entry index when a single entry exceeds 1 MB', async () => {
const session = datadogDestination.openSession({ config, credentials })
// Two entries; the second exceeds the 1 MB per-entry limit.
const huge = 'x'.repeat(1024 * 1024 + 10)
const body = Buffer.from(
`${JSON.stringify({ id: 'small' })}\n${JSON.stringify({ blob: huge })}\n`,
'utf8'
)
await expect(
session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta(0),
signal: new AbortController().signal,
})
).rejects.toThrow(/entry at index 1 is .* exceeds the 1048576-byte per-entry limit/)
expect(fetchMock).not.toHaveBeenCalled()
await session.close()
})
it('gzips payloads larger than 1KB and sets Content-Encoding: gzip', async () => {
const session = datadogDestination.openSession({ config, credentials })
// Build > 1KB raw payload; padding string is JSON-safe.
const padding = 'a'.repeat(2048)
const body = Buffer.from(`${JSON.stringify({ id: 'a', big: padding })}\n`, 'utf8')
await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta(0),
signal: new AbortController().signal,
})
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
const headers = init.headers as Record<string, string>
expect(headers['Content-Encoding']).toBe('gzip')
expect(init.body).toBeInstanceOf(Uint8Array)
expect(typeof init.body).not.toBe('string')
const decoded = JSON.parse(gunzipSync(init.body as Uint8Array).toString('utf8'))
expect(decoded).toHaveLength(1)
expect(decoded[0].id).toBe('a')
expect(decoded[0].big).toBe(padding)
await session.close()
})
it('locator includes the dd-request-id header when present', async () => {
fetchMock.mockResolvedValueOnce(
new Response(null, { status: 202, headers: { 'dd-request-id': 'req-abc-123' } })
)
const session = datadogDestination.openSession({ config, credentials })
const result = await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta(7),
signal: new AbortController().signal,
})
expect(result.locator).toBe('datadog://us1#r1-7@req-abc-123')
await session.close()
})
})
describe('datadogDestination test()', () => {
it('sends a single probe entry', async () => {
await datadogDestination.test!({
config,
credentials,
signal: new AbortController().signal,
})
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
const headers = init.headers as Record<string, string>
expect(headers.Accept).toBe('application/json')
expect(headers['User-Agent']).toBe('sim-data-drain/1.0')
const payload = JSON.parse(init.body as string)
expect(payload).toHaveLength(1)
expect(payload[0].message).toContain('connection test')
})
})
@@ -0,0 +1,270 @@
import { gzipSync } from 'node:zlib'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { z } from 'zod'
import { parseNdjsonObjects, sleepUntilAborted } from '@/lib/data-drains/destinations/utils'
import type { DeliveryMetadata, DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainDatadogDestination')
const DATADOG_SITES = ['us1', 'us3', 'us5', 'eu1', 'ap1', 'ap2', 'gov'] as const
type DatadogSite = (typeof DATADOG_SITES)[number]
const SITE_HOSTS: Record<DatadogSite, string> = {
us1: 'datadoghq.com',
us3: 'us3.datadoghq.com',
us5: 'us5.datadoghq.com',
eu1: 'datadoghq.eu',
ap1: 'ap1.datadoghq.com',
ap2: 'ap2.datadoghq.com',
gov: 'ddog-gov.com',
}
const MAX_ATTEMPTS = 4
const PER_ATTEMPT_TIMEOUT_MS = 30_000
const MAX_UNCOMPRESSED_BYTES = 5 * 1024 * 1024
const MAX_WIRE_BYTES = 6 * 1024 * 1024
const MAX_ENTRY_BYTES = 1024 * 1024
const MAX_ENTRIES_PER_REQUEST = 1000
const GZIP_THRESHOLD_BYTES = 1024
/**
* Datadog tag format: comma-separated `key:value` pairs. Each key must start
* with a letter and contain only [A-Za-z0-9_:./-]. Validating here so the
* `ddtags` header we emit can't be mangled by user-supplied free-form input.
*/
const DATADOG_TAG_PAIR_RE = /^[A-Za-z][A-Za-z0-9_./-]*:[^,\s][^,]*$/
const datadogConfigSchema = z.object({
site: z.enum(DATADOG_SITES),
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 datadogCredentialsSchema = z.object({
apiKey: z.string().min(1, 'apiKey is required'),
})
export type DatadogDestinationConfig = z.infer<typeof datadogConfigSchema>
export type DatadogDestinationCredentials = z.infer<typeof datadogCredentialsSchema>
interface DatadogLogEntry {
ddsource: string
service: string
ddtags: string
message: string
[attribute: string]: unknown
}
function buildEndpoint(site: DatadogSite): string {
return `https://http-intake.logs.${SITE_HOSTS[site]}/api/v2/logs`
}
function buildEntries(
rows: unknown[],
config: DatadogDestinationConfig,
metadata: DeliveryMetadata
): DatadogLogEntry[] {
const ddtags = [
`sim_drain_id:${metadata.drainId}`,
`sim_run_id:${metadata.runId}`,
`sim_source:${metadata.source}`,
...(config.tags ? [config.tags] : []),
].join(',')
const service = config.service ?? 'sim'
return rows.map((row) => {
const attrs = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {}
let message: string
if (typeof row === 'string') {
message = row
} else if (typeof attrs.message === 'string') {
message = attrs.message
} else {
message = JSON.stringify(row)
}
/**
* Spread user attributes first, then force all four reserved fields the
* drain owns: `ddsource`, `service`, `ddtags`, and `message`. Per
* https://docs.datadoghq.com/logs/log_configuration/pipelines/#service-and-source,
* Datadog uses `service` + `ddsource` to pick the processing pipeline, so
* letting a row field clobber them would silently re-route a drain.
*/
return {
...attrs,
ddsource: 'sim',
service,
ddtags,
message,
}
})
}
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500
}
interface PreparedBody {
body: Uint8Array | string
headers: Record<string, string>
wireBytes: number
rawBytes: number
}
interface PostInput {
url: string
prepared: PreparedBody
signal: AbortSignal
}
function buildRequestBody(payload: string, apiKey: string): PreparedBody {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'DD-API-KEY': apiKey,
Accept: 'application/json',
'User-Agent': 'sim-data-drain/1.0',
}
const rawBytes = Buffer.byteLength(payload, 'utf8')
if (rawBytes > GZIP_THRESHOLD_BYTES) {
const compressed = gzipSync(payload)
headers['Content-Encoding'] = 'gzip'
const view = new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.byteLength)
return { body: view, headers, wireBytes: view.byteLength, rawBytes }
}
return { body: payload, headers, wireBytes: rawBytes, rawBytes }
}
async function postWithRetries(input: PostInput): Promise<Response> {
const { body, headers } = input.prepared
let lastError: unknown
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
let retryAfterMs: number | null = null
let response: Response | undefined
try {
response = await fetch(input.url, {
method: 'POST',
// double-cast-allowed: Uint8Array is a valid runtime BodyInit but the DOM lib types only enumerate Blob/FormData/string/etc.
body: body as unknown as BodyInit,
headers,
signal: perAttempt,
})
} catch (error) {
lastError = error
logger.debug('Datadog request failed', { attempt, error: toError(error).message })
}
if (response) {
if (response.ok) {
/** Drain the success body so undici can return the socket to the keep-alive pool. Headers remain readable after consumption. */
await response.text().catch(() => '')
return response
}
if (!isRetryableStatus(response.status)) {
const text = await response.text().catch(() => '')
throw new Error(`Datadog responded with HTTP ${response.status}: ${text}`)
}
lastError = new Error(`Datadog responded with HTTP ${response.status}`)
retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
/** Drain the retryable response body so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
}
if (attempt < MAX_ATTEMPTS) {
await sleepUntilAborted(backoffWithJitter(attempt, retryAfterMs), input.signal)
}
}
throw lastError instanceof Error ? lastError : new Error('Datadog delivery failed after retries')
}
export const datadogDestination: DrainDestination<
DatadogDestinationConfig,
DatadogDestinationCredentials
> = {
type: 'datadog',
displayName: 'Datadog',
configSchema: datadogConfigSchema,
credentialsSchema: datadogCredentialsSchema,
async test({ config, credentials, signal }) {
const probe = [
{
ddsource: 'sim',
service: config.service ?? 'sim',
ddtags: `sim_probe:1${config.tags ? `,${config.tags}` : ''}`,
message: 'sim-data-drain connection test',
},
]
await postWithRetries({
url: buildEndpoint(config.site),
prepared: buildRequestBody(JSON.stringify(probe), credentials.apiKey),
signal,
})
},
openSession({ config, credentials }) {
const url = buildEndpoint(config.site)
return {
async deliver({ body, metadata, signal }) {
const rows = parseNdjsonObjects(body)
const entries = buildEntries(rows, config, metadata)
if (entries.length > MAX_ENTRIES_PER_REQUEST) {
throw new Error(
`Datadog chunk has ${entries.length} entries, exceeds the ${MAX_ENTRIES_PER_REQUEST} per-request limit`
)
}
for (let i = 0; i < entries.length; i++) {
const entryBytes = Buffer.byteLength(JSON.stringify(entries[i]), 'utf8')
if (entryBytes > MAX_ENTRY_BYTES) {
throw new Error(
`Datadog entry at index ${i} is ${entryBytes} bytes, exceeds the ${MAX_ENTRY_BYTES}-byte per-entry limit`
)
}
}
const payload = JSON.stringify(entries)
const prepared = buildRequestBody(payload, credentials.apiKey)
if (prepared.rawBytes > MAX_UNCOMPRESSED_BYTES) {
throw new Error(
`Datadog payload is ${prepared.rawBytes} bytes uncompressed, exceeds the ${MAX_UNCOMPRESSED_BYTES}-byte per-request limit`
)
}
if (prepared.wireBytes > MAX_WIRE_BYTES) {
throw new Error(
`Datadog payload is ${prepared.wireBytes} bytes on the wire, exceeds the ${MAX_WIRE_BYTES}-byte defensive wire-size cap`
)
}
const response = await postWithRetries({
url,
prepared,
signal,
})
const requestId = response.headers.get('dd-request-id') ?? null
logger.debug('Datadog chunk delivered', {
site: config.site,
rows: entries.length,
rawBytes: prepared.rawBytes,
wireBytes: prepared.wireBytes,
})
return {
locator: requestId
? `datadog://${config.site}#${metadata.runId}-${metadata.sequence}@${requestId}`
: `datadog://${config.site}#${metadata.runId}-${metadata.sequence}`,
}
},
async close() {},
}
},
}
@@ -0,0 +1,192 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetAccessToken, JWTCtor } = vi.hoisted(() => {
const mockGetAccessToken = vi.fn(async () => ({ token: 'fake-access-token' }))
return {
mockGetAccessToken,
JWTCtor: vi.fn().mockImplementation(
class {
getAccessToken = mockGetAccessToken
}
),
}
})
vi.mock('google-auth-library', () => ({ JWT: JWTCtor }))
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
import { gcsDestination } from '@/lib/data-drains/destinations/gcs'
const config = { bucket: 'my-bucket', prefix: 'sim/' }
const credentials = {
serviceAccountJson: JSON.stringify({
client_email: 'sa@project.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n',
}),
}
beforeEach(() => {
vi.clearAllMocks()
fetchMock.mockResolvedValue(new Response(null, { status: 200 }))
})
describe('gcsDestination openSession', () => {
it('uploads via the JSON API and returns a gs:// locator', async () => {
const session = gcsDestination.openSession({ config, credentials })
const body = Buffer.from('row\n', 'utf8')
const result = await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd1',
runId: 'r1',
source: 'workflow_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
expect(result.locator).toMatch(
/^gs:\/\/my-bucket\/sim\/workflow_logs\/d1\/\d{4}\/\d{2}\/\d{2}\/r1-00000\.ndjson$/
)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/upload/storage/v1/b/my-bucket/o')
expect(url).toContain('uploadType=media')
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer fake-access-token')
expect(headers['Content-Type']).toBe('application/x-ndjson')
expect(headers['x-goog-meta-sim-drain-id']).toBe('d1')
expect(headers['x-goog-meta-sim-sequence']).toBe('0')
await session.close()
})
it('surfaces non-2xx responses as errors', async () => {
fetchMock.mockResolvedValueOnce(
new Response('Permission denied', { status: 403, statusText: 'Forbidden' })
)
const session = gcsDestination.openSession({ config, credentials })
await expect(
session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd',
runId: 'r',
source: 'audit_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
).rejects.toThrow(/HTTP 403/)
await session.close()
})
})
describe('gcsDestination test()', () => {
it('writes a probe object then attempts cleanup', async () => {
await gcsDestination.test!({
config,
credentials,
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(2)
const [, deleteCall] = fetchMock.mock.calls
expect((deleteCall[1] as RequestInit).method).toBe('DELETE')
})
})
describe('gcsDestination credentials schema', () => {
it('rejects invalid JSON', () => {
const result = gcsDestination.credentialsSchema.safeParse({ serviceAccountJson: 'not-json' })
expect(result.success).toBe(false)
})
it('rejects JSON missing client_email', () => {
const result = gcsDestination.credentialsSchema.safeParse({
serviceAccountJson: JSON.stringify({ private_key: 'k' }),
})
expect(result.success).toBe(false)
})
})
describe('gcsDestination config schema', () => {
it('accepts a 3-character bucket name', () => {
const result = gcsDestination.configSchema.safeParse({ bucket: 'abc' })
expect(result.success).toBe(true)
})
it('rejects bucket names beginning with goog or containing google', () => {
expect(gcsDestination.configSchema.safeParse({ bucket: 'goog-prefixed' }).success).toBe(false)
expect(gcsDestination.configSchema.safeParse({ bucket: 'my-google-bucket' }).success).toBe(
false
)
expect(gcsDestination.configSchema.safeParse({ bucket: 'g00gle-bucket' }).success).toBe(false)
})
})
describe('gcsDestination upload headers', () => {
it('does not set a Content-Length header on uploads', async () => {
const session = gcsDestination.openSession({ config, credentials })
await session.deliver({
body: Buffer.from('row\n', 'utf8'),
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd1',
runId: 'r1',
source: 'workflow_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Record<string, string>
const headerKeys = Object.keys(headers).map((k) => k.toLowerCase())
expect(headerKeys).not.toContain('content-length')
expect(headers['User-Agent']).toBe('sim-data-drain/1.0')
await session.close()
})
})
describe('gcsDestination deleteObject behavior', () => {
it('treats 404 as success on delete during test() cleanup', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })) // upload probe
fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 })) // delete probe
await expect(
gcsDestination.test!({
config,
credentials,
signal: new AbortController().signal,
})
).resolves.toBeUndefined()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('retries DELETE on 503 then succeeds on 200', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })) // upload probe
fetchMock.mockResolvedValueOnce(new Response('busy', { status: 503 })) // first delete attempt
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })) // retry succeeds
await gcsDestination.test!({
config,
credentials,
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
const [, deleteCall1] = fetchMock.mock.calls[1] as [string, RequestInit]
const [, deleteCall2] = fetchMock.mock.calls[2] as [string, RequestInit]
expect(deleteCall1.method).toBe('DELETE')
expect(deleteCall2.method).toBe('DELETE')
})
})
@@ -0,0 +1,320 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { JWT } from 'google-auth-library'
import { z } from 'zod'
import {
buildObjectKey,
normalizePrefix,
type ParsedServiceAccount,
parseServiceAccount,
refineServiceAccountJson,
sleepUntilAborted,
} from '@/lib/data-drains/destinations/utils'
import type { DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainGCSDestination')
const SCOPE = 'https://www.googleapis.com/auth/devstorage.read_write'
const GCS_HOST = 'https://storage.googleapis.com'
const USER_AGENT = 'sim-data-drain/1.0'
const MAX_ATTEMPTS = 4
const PER_ATTEMPT_TIMEOUT_MS = 60_000
/** GCS caps total custom metadata at 8 KiB per object (sum of key + value bytes). */
const MAX_CUSTOM_METADATA_BYTES = 8 * 1024
/** GCS object names are at most 1024 bytes when UTF-8 encoded (flat-namespace buckets). */
const MAX_OBJECT_NAME_BYTES = 1024
const GCS_BUCKET_COMPONENT_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/
const IPV4_LIKE_RE = /^(\d{1,3}\.){3}\d{1,3}$/
const GOOGLE_RESERVED_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
}
const gcsConfigSchema = 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) => !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 a dash adjacent to a dot',
})
.refine((v) => !GOOGLE_RESERVED_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 gcsCredentialsSchema = z
.object({
serviceAccountJson: z.string().min(1, 'serviceAccountJson is required'),
})
.superRefine(refineServiceAccountJson)
export type GCSDestinationConfig = z.infer<typeof gcsConfigSchema>
export type GCSDestinationCredentials = z.infer<typeof gcsCredentialsSchema>
function buildJwt(account: ParsedServiceAccount): JWT {
return new JWT({ email: account.clientEmail, key: account.privateKey, scopes: [SCOPE] })
}
async function getAccessToken(jwt: JWT): Promise<string> {
const { token } = await jwt.getAccessToken()
if (!token) throw new Error('Failed to obtain GCS access token')
return token
}
interface UploadInput {
bucket: string
objectName: string
body: Buffer
contentType: string
metadata: Record<string, string>
signal: AbortSignal
jwt: JWT
}
function isRetryableStatus(status: number): boolean {
return (
status === 408 ||
status === 429 ||
status === 500 ||
status === 502 ||
status === 503 ||
status === 504
)
}
interface RetryRequestInput {
action: string
bucket: string
url: string
method: string
/**
* Built per attempt so the OAuth access token is refreshed if it expired
* between retries (google-auth-library caches and refreshes on demand).
*/
buildHeaders: () => Promise<Record<string, string>>
body?: BodyInit | Buffer
signal: AbortSignal
/** HTTP statuses to treat as success in addition to 2xx. */
successStatuses?: number[]
}
async function fetchWithRetry(input: RetryRequestInput): Promise<void> {
let lastError: unknown
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
let response: Response
try {
const headers = await input.buildHeaders()
response = await fetch(input.url, {
method: input.method,
body: input.body as BodyInit | undefined,
headers,
signal: perAttempt,
})
} catch (error) {
lastError = error
logger.debug('GCS request failed', {
action: input.action,
attempt,
bucket: input.bucket,
error: toError(error).message,
})
if (attempt < MAX_ATTEMPTS) {
await sleepUntilAborted(backoffWithJitter(attempt, null), input.signal)
continue
}
throw error
}
if (response.ok || input.successStatuses?.includes(response.status)) {
/** Drain the success body so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
return
}
if (!isRetryableStatus(response.status) || attempt === MAX_ATTEMPTS) {
const text = await response.text().catch(() => '')
logger.warn('GCS operation failed', {
action: input.action,
bucket: input.bucket,
status: response.status,
})
throw new Error(
`GCS ${input.action} failed (HTTP ${response.status}): ${text || response.statusText}`
)
}
lastError = new Error(`GCS ${input.action} responded with HTTP ${response.status}`)
const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
/** Drain the retryable response body so undici can return the socket to the keep-alive pool. */
await response.text().catch(() => '')
await sleepUntilAborted(backoffWithJitter(attempt, retryAfterMs), input.signal)
}
throw lastError instanceof Error
? lastError
: new Error(`GCS ${input.action} failed after retries`)
}
/** GCS uses HTTP headers (x-goog-meta-*) to carry custom metadata; the spec forbids non-ASCII. */
const ASCII_ONLY_RE = /^[\x20-\x7e]*$/
async function uploadObject(action: string, input: UploadInput): Promise<void> {
const objectNameBytes = Buffer.byteLength(input.objectName, 'utf8')
if (objectNameBytes < 1 || objectNameBytes > MAX_OBJECT_NAME_BYTES) {
throw new Error(
`GCS object name is ${objectNameBytes} bytes, must be 1-${MAX_OBJECT_NAME_BYTES} bytes (UTF-8)`
)
}
let metadataBytes = 0
for (const [key, value] of Object.entries(input.metadata)) {
if (!ASCII_ONLY_RE.test(key) || !ASCII_ONLY_RE.test(value)) {
throw new Error(`GCS custom metadata key/value must be ASCII printable: ${key}`)
}
metadataBytes += Buffer.byteLength(key, 'utf8') + Buffer.byteLength(value, 'utf8')
}
if (metadataBytes > MAX_CUSTOM_METADATA_BYTES) {
throw new Error(
`GCS custom metadata is ${metadataBytes} bytes, exceeds the ${MAX_CUSTOM_METADATA_BYTES}-byte per-object limit`
)
}
const url = `${GCS_HOST}/upload/storage/v1/b/${encodeURIComponent(input.bucket)}/o?uploadType=media&name=${encodeURIComponent(input.objectName)}`
await fetchWithRetry({
action,
bucket: input.bucket,
url,
method: 'POST',
buildHeaders: async () => {
const token = await getAccessToken(input.jwt)
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
'Content-Type': input.contentType,
'User-Agent': USER_AGENT,
}
for (const [key, value] of Object.entries(input.metadata)) {
headers[`x-goog-meta-${key}`] = value
}
return headers
},
body: input.body,
signal: input.signal,
})
}
async function deleteObject(input: {
bucket: string
objectName: string
jwt: JWT
signal: AbortSignal
}): Promise<void> {
const url = `${GCS_HOST}/storage/v1/b/${encodeURIComponent(input.bucket)}/o/${encodeURIComponent(input.objectName)}`
await fetchWithRetry({
action: 'delete-object',
bucket: input.bucket,
url,
method: 'DELETE',
buildHeaders: async () => {
const token = await getAccessToken(input.jwt)
return {
Authorization: `Bearer ${token}`,
'User-Agent': USER_AGENT,
}
},
signal: input.signal,
successStatuses: [404],
})
}
export const gcsDestination: DrainDestination<GCSDestinationConfig, GCSDestinationCredentials> = {
type: 'gcs',
displayName: 'Google Cloud Storage',
configSchema: gcsConfigSchema,
credentialsSchema: gcsCredentialsSchema,
async test({ config, credentials, signal }) {
const account = parseServiceAccount(credentials.serviceAccountJson)
const jwt = buildJwt(account)
const probeName = `${normalizePrefix(config.prefix)}.sim-drain-write-probe/${generateShortId(12)}`
await uploadObject('test-put', {
bucket: config.bucket,
objectName: probeName,
body: Buffer.alloc(0),
contentType: 'application/octet-stream',
metadata: {},
signal,
jwt,
})
try {
await deleteObject({ bucket: config.bucket, objectName: probeName, jwt, signal })
} catch (cleanupError) {
logger.debug('GCS test write probe cleanup failed (non-fatal)', {
bucket: config.bucket,
objectName: probeName,
error: cleanupError,
})
}
},
openSession({ config, credentials }) {
const account = parseServiceAccount(credentials.serviceAccountJson)
const jwt = buildJwt(account)
return {
async deliver({ body, contentType, metadata, signal }) {
const objectName = buildObjectKey(config.prefix, metadata)
await uploadObject('put-object', {
bucket: config.bucket,
objectName,
body,
contentType,
metadata: {
'sim-drain-id': metadata.drainId,
'sim-run-id': metadata.runId,
'sim-source': metadata.source,
'sim-sequence': metadata.sequence.toString(),
'sim-row-count': metadata.rowCount.toString(),
},
signal,
jwt,
})
logger.debug('GCS chunk delivered', {
bucket: config.bucket,
objectName,
bytes: body.byteLength,
})
return { locator: `gs://${config.bucket}/${objectName}` }
},
async close() {},
}
},
}
@@ -0,0 +1,22 @@
import { azureBlobDestination } from '@/lib/data-drains/destinations/azure_blob'
import { bigqueryDestination } from '@/lib/data-drains/destinations/bigquery'
import { datadogDestination } from '@/lib/data-drains/destinations/datadog'
import { gcsDestination } from '@/lib/data-drains/destinations/gcs'
import { s3Destination } from '@/lib/data-drains/destinations/s3'
import { snowflakeDestination } from '@/lib/data-drains/destinations/snowflake'
import { webhookDestination } from '@/lib/data-drains/destinations/webhook'
import type { DestinationType, DrainDestination } from '@/lib/data-drains/types'
export const DESTINATION_REGISTRY = {
s3: s3Destination,
gcs: gcsDestination,
azure_blob: azureBlobDestination,
datadog: datadogDestination,
bigquery: bigqueryDestination,
snowflake: snowflakeDestination,
webhook: webhookDestination,
} as const satisfies Record<DestinationType, DrainDestination>
export function getDestination(type: DestinationType): DrainDestination {
return DESTINATION_REGISTRY[type]
}
@@ -0,0 +1,178 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSend, mockDestroy, S3ClientCtor, PutObjectCommandCtor, DeleteObjectCommandCtor } =
vi.hoisted(() => {
const mockSend = vi.fn(async () => ({}))
const mockDestroy = vi.fn()
return {
mockSend,
mockDestroy,
S3ClientCtor: vi.fn().mockImplementation(
class {
send = mockSend
destroy = mockDestroy
}
),
PutObjectCommandCtor: vi.fn().mockImplementation(
class {
__cmd = 'put'
args: unknown
constructor(args: unknown) {
this.args = args
}
}
),
DeleteObjectCommandCtor: vi.fn().mockImplementation(
class {
__cmd = 'delete'
args: unknown
constructor(args: unknown) {
this.args = args
}
}
),
}
})
vi.mock('@aws-sdk/client-s3', () => ({
S3Client: S3ClientCtor,
PutObjectCommand: PutObjectCommandCtor,
DeleteObjectCommand: DeleteObjectCommandCtor,
}))
import { s3Destination } from '@/lib/data-drains/destinations/s3'
const config = {
bucket: 'my-bucket',
region: 'us-east-1',
prefix: 'sim/',
}
const credentials = { accessKeyId: 'AKID', secretAccessKey: 'SECRET' }
beforeEach(() => {
vi.clearAllMocks()
})
describe('s3Destination openSession', () => {
it('reuses one S3Client across multiple deliveries and destroys on close', async () => {
const session = s3Destination.openSession({ config, credentials })
expect(S3ClientCtor).toHaveBeenCalledTimes(1)
const body = Buffer.from('row\n', 'utf8')
const meta = (sequence: number) => ({
drainId: 'd1',
runId: 'r1',
source: 'workflow_logs' as const,
sequence,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
})
const signal = new AbortController().signal
const res1 = await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta(0),
signal,
})
const res2 = await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta(1),
signal,
})
expect(S3ClientCtor).toHaveBeenCalledTimes(1)
expect(mockSend).toHaveBeenCalledTimes(2)
expect(res1.locator).toMatch(
/^s3:\/\/my-bucket\/sim\/workflow_logs\/d1\/\d{4}\/\d{2}\/\d{2}\/r1-00000\.ndjson$/
)
expect(res2.locator).toMatch(/r1-00001\.ndjson$/)
const putArgs = (PutObjectCommandCtor.mock.calls[0]?.[0] ?? {}) as Record<string, unknown>
expect(putArgs.Bucket).toBe('my-bucket')
expect(putArgs.Body).toBe(body)
expect(putArgs.ContentType).toBe('application/x-ndjson')
expect((putArgs.Metadata as Record<string, string>)['sim-drain-id']).toBe('d1')
expect((putArgs.Metadata as Record<string, string>)['sim-sequence']).toBe('0')
await session.close()
expect(mockDestroy).toHaveBeenCalledTimes(1)
})
it('omits the prefix segment when prefix is empty', async () => {
const session = s3Destination.openSession({
config: { bucket: 'b', region: 'us-east-1' },
credentials,
})
const result = await session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd',
runId: 'r',
source: 'audit_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
expect(result.locator).toMatch(
/^s3:\/\/b\/audit_logs\/d\/\d{4}\/\d{2}\/\d{2}\/r-00000\.ndjson$/
)
await session.close()
})
it('surfaces AWS error code in delivery errors', async () => {
mockSend.mockRejectedValueOnce(
Object.assign(new Error('Access Denied'), {
name: 'AccessDenied',
$metadata: { httpStatusCode: 403, requestId: 'req-1' },
})
)
const session = s3Destination.openSession({ config, credentials })
await expect(
session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata: {
drainId: 'd',
runId: 'r',
source: 'audit_logs',
sequence: 0,
rowCount: 1,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
},
signal: new AbortController().signal,
})
).rejects.toThrow(/AccessDenied 403/)
await session.close()
})
})
describe('s3Destination test()', () => {
it('writes a probe object then attempts cleanup', async () => {
await s3Destination.test!({
config,
credentials,
signal: new AbortController().signal,
})
expect(PutObjectCommandCtor).toHaveBeenCalled()
expect(DeleteObjectCommandCtor).toHaveBeenCalled()
expect(mockDestroy).toHaveBeenCalled()
})
it('still returns success when cleanup delete fails', async () => {
mockSend
.mockResolvedValueOnce({}) // put probe
.mockRejectedValueOnce(new Error('no delete perms')) // cleanup
await expect(
s3Destination.test!({ config, credentials, signal: new AbortController().signal })
).resolves.toBeUndefined()
})
})
+259
View File
@@ -0,0 +1,259 @@
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
type S3ServiceException,
} from '@aws-sdk/client-s3'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { z } from 'zod'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { buildObjectKey, normalizePrefix } from '@/lib/data-drains/destinations/utils'
import type { DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainS3Destination')
/** https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html */
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}$/
/** Matches standard and 4-segment ISO partition codes (e.g. `us-iso-east-1`). */
const AWS_REGION_RE = /^[a-z]{2,}(-[a-z]+)+-\d+$/
/** Cap is over key + value bytes only (no `x-amz-meta-` prefix). */
const MAX_S3_METADATA_BYTES = 2 * 1024
const MAX_S3_KEY_BYTES = 1024
const s3BucketSchema = 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, only letters/digits/./-',
})
.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'), {
message: 'bucket must not end with reserved suffix (-s3alias, --ol-s3, .mrap)',
})
.refine((v) => !v.endsWith('--x-s3'), {
message:
'bucket must not end with "--x-s3" (reserved for S3 Express One Zone directory buckets)',
})
.refine((v) => !v.endsWith('--table-s3'), {
message: 'bucket must not end with "--table-s3" (reserved for S3 Tables)',
})
const s3RegionSchema = 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',
})
const s3ConfigSchema = z.object({
bucket: s3BucketSchema,
region: s3RegionSchema,
/**
* Optional prefix; trailing slash is added automatically when assembling keys.
* Bounded by UTF-8 byte length (not code units) so non-ASCII prefixes can't
* push assembled keys past S3's 1024-byte object key limit.
*/
prefix: z
.string()
.max(512)
.refine((v) => Buffer.byteLength(v, 'utf8') <= 512, {
message: 'prefix must be at most 512 bytes (UTF-8)',
})
.optional(),
/**
* Optional override for non-AWS S3-compatible providers (MinIO, R2, GCS interop, etc.).
* SSRF-validated: HTTPS-only, must not resolve syntactically to a private,
* loopback, or cloud-metadata address. The AWS SDK will issue requests to
* this host, so we reject internal targets at the schema boundary.
*/
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(),
/**
* Force path-style addressing. Set `true` for MinIO / Ceph RGW; defaults
* to `false` for AWS S3 and Cloudflare R2.
*/
forcePathStyle: z.boolean().optional(),
})
const s3CredentialsSchema = z.object({
accessKeyId: z.string().min(1, 'accessKeyId is required'),
secretAccessKey: z.string().min(1, 'secretAccessKey is required'),
})
export type S3DestinationConfig = z.infer<typeof s3ConfigSchema>
export type S3DestinationCredentials = z.infer<typeof s3CredentialsSchema>
function buildClient(config: S3DestinationConfig, credentials: S3DestinationCredentials): S3Client {
return new S3Client({
region: config.region,
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
},
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle ?? false,
})
}
function isS3ServiceException(error: unknown): error is S3ServiceException {
return (
typeof error === 'object' &&
error !== null &&
'$metadata' in error &&
typeof (error as { name?: unknown }).name === 'string'
)
}
/** DNS-aware SSRF check: catches hostnames that resolve to internal IPs (the schema check only catches IP literals). */
async function assertEndpointIsPublic(endpoint: string | undefined): Promise<void> {
if (!endpoint) return
const result = await validateUrlWithDNS(endpoint, 'endpoint')
if (!result.isValid) {
throw new Error(result.error ?? 'S3 endpoint failed SSRF validation')
}
}
/**
* Surfaces actionable S3 SDK error codes (`AccessDenied`, `NoSuchBucket`,
* `InvalidAccessKeyId`, `SignatureDoesNotMatch`, ...) and preserves the
* original error as `cause` so callers can still branch on `code`/`$metadata`.
*/
async function withS3ErrorContext<T>(action: string, fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (error) {
if (isS3ServiceException(error)) {
const code = error.name
const status = error.$metadata?.httpStatusCode
const requestId = error.$metadata?.requestId
logger.warn('S3 operation failed', { action, code, status, requestId })
/** Preserve SDK error as `cause` so callers can still branch on `code` / `$metadata`. */
throw new Error(
`S3 ${action} failed (${code}${status ? ` ${status}` : ''}): ${error.message}`,
{ cause: error }
)
}
throw error
}
}
export const s3Destination: DrainDestination<S3DestinationConfig, S3DestinationCredentials> = {
type: 's3',
displayName: 'Amazon S3',
configSchema: s3ConfigSchema,
credentialsSchema: s3CredentialsSchema,
async test({ config, credentials, signal }) {
await assertEndpointIsPublic(config.endpoint)
const client = buildClient(config, credentials)
/** Real write probe so write-only IAM policies surface here, not at first run. */
const probeKey = `${normalizePrefix(config.prefix)}.sim-drain-write-probe/${generateShortId(12)}`
try {
await withS3ErrorContext('test-put', () =>
client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: probeKey,
Body: Buffer.alloc(0),
ContentType: 'application/octet-stream',
ServerSideEncryption: 'AES256',
}),
{ abortSignal: signal }
)
)
/** Best-effort cleanup: write was already proven, so a missing s3:DeleteObject must not fail the test. */
try {
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: probeKey }), {
abortSignal: signal,
})
} catch (cleanupError) {
logger.debug('S3 test write probe cleanup failed (non-fatal)', {
bucket: config.bucket,
key: probeKey,
error: cleanupError,
})
}
} finally {
client.destroy()
}
},
openSession({ config, credentials }) {
const client = buildClient(config, credentials)
/**
* Lazy + cached DNS-aware endpoint check. SDK manages its own connections
* so we can't pin the IP, but failing the first deliver still rejects
* hostnames that resolve to internal targets. Lazy init avoids an
* unhandled rejection when the source yields no chunks.
*/
let endpointCheck: Promise<void> | null = null
return {
async deliver({ body, contentType, metadata, signal }) {
if (endpointCheck === null) endpointCheck = assertEndpointIsPublic(config.endpoint)
await endpointCheck
const key = buildObjectKey(config.prefix, metadata)
const keyBytes = Buffer.byteLength(key, 'utf8')
if (keyBytes > MAX_S3_KEY_BYTES) {
throw new Error(
`S3 object key is ${keyBytes} bytes, exceeds the ${MAX_S3_KEY_BYTES}-byte limit`
)
}
const userMetadata: Record<string, string> = {
'sim-drain-id': metadata.drainId,
'sim-run-id': metadata.runId,
'sim-source': metadata.source,
'sim-sequence': metadata.sequence.toString(),
'sim-row-count': metadata.rowCount.toString(),
}
let metadataBytes = 0
for (const [k, v] of Object.entries(userMetadata)) {
metadataBytes += Buffer.byteLength(k, 'utf8') + Buffer.byteLength(v, 'utf8')
}
if (metadataBytes > MAX_S3_METADATA_BYTES) {
throw new Error(
`S3 user metadata is ${metadataBytes} bytes, exceeds the ${MAX_S3_METADATA_BYTES}-byte per-object limit`
)
}
await withS3ErrorContext('put-object', () =>
client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: body,
ContentType: contentType,
ServerSideEncryption: 'AES256',
Metadata: userMetadata,
}),
{ abortSignal: signal }
)
)
logger.debug('S3 chunk delivered', { bucket: config.bucket, key, bytes: body.byteLength })
return { locator: `s3://${config.bucket}/${key}` }
},
async close() {
client.destroy()
},
}
},
}
@@ -0,0 +1,210 @@
/**
* @vitest-environment node
*/
import { generateKeyPairSync } from 'node:crypto'
import { decodeJwt } from 'jose'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fetchMock = vi.fn(async () => new Response(JSON.stringify({}), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
import { snowflakeDestination } from '@/lib/data-drains/destinations/snowflake'
const { privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' },
})
const config = {
account: 'orgname-acct',
user: 'sim_user',
warehouse: 'WH',
database: 'DB',
schema: 'PUBLIC',
table: 'DRAINS',
}
const credentials = { privateKey }
const meta = {
drainId: 'd',
runId: 'r',
source: 'workflow_logs' as const,
sequence: 0,
rowCount: 2,
runStartedAt: new Date('2025-06-15T12:00:00Z'),
}
beforeEach(() => {
vi.clearAllMocks()
fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }))
})
describe('snowflakeDestination', () => {
it('posts a multi-row INSERT with TEXT bindings and a Bearer JWT', async () => {
const session = snowflakeDestination.openSession({ config, credentials })
const body = Buffer.from(
`${JSON.stringify({ id: 'a' })}\n${JSON.stringify({ id: 'b' })}\n`,
'utf8'
)
await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toMatch(
/^https:\/\/orgname-acct\.snowflakecomputing\.com\/api\/v2\/statements\?requestId=[0-9a-f-]+$/
)
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toMatch(/^Bearer ey/)
expect(headers['X-Snowflake-Authorization-Token-Type']).toBe('KEYPAIR_JWT')
const payload = JSON.parse(init.body as string)
expect(payload.statement).toContain('INSERT INTO "DB"."PUBLIC"."DRAINS"')
expect(payload.statement).toContain('VALUES (PARSE_JSON(?)), (PARSE_JSON(?))')
expect(payload.statement.match(/PARSE_JSON\(\?\)/g)).toHaveLength(2)
expect(payload.bindings['1']).toEqual({ type: 'TEXT', value: JSON.stringify({ id: 'a' }) })
expect(payload.bindings['2']).toEqual({ type: 'TEXT', value: JSON.stringify({ id: 'b' }) })
expect(payload.warehouse).toBe('WH')
await session.close()
})
it('uses the configured column when provided', async () => {
const session = snowflakeDestination.openSession({
config: { ...config, column: 'payload' },
credentials,
})
await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
const payload = JSON.parse(init.body as string)
expect(payload.statement).toContain('("payload")')
await session.close()
})
it('strips region/cloud suffix from the JWT iss/sub', async () => {
const session = snowflakeDestination.openSession({
config: { ...config, account: 'orgname-acct.us-east-1.aws' },
credentials,
})
await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
const headers = init.headers as Record<string, string>
const token = headers.Authorization.replace(/^Bearer /, '')
const claims = decodeJwt(token)
expect(claims.sub).toBe('ORGNAME-ACCT.SIM_USER')
expect(claims.iss).toMatch(/^ORGNAME-ACCT\.SIM_USER\.SHA256:/)
await session.close()
})
it('polls /statements/{handle} when Snowflake returns 202', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ statementHandle: 'h-1' }), { status: 202 })
)
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 202 }))
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
const session = snowflakeDestination.openSession({ config, credentials })
await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
const pollUrl = fetchMock.mock.calls[1]?.[0]
expect(pollUrl).toBe('https://orgname-acct.snowflakecomputing.com/api/v2/statements/h-1')
await session.close()
})
it('parses CRLF NDJSON bodies correctly', async () => {
const session = snowflakeDestination.openSession({ config, credentials })
const body = Buffer.from(
`${JSON.stringify({ id: 'a' })}\r\n${JSON.stringify({ id: 'b' })}\r\n`,
'utf8'
)
await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
const payload = JSON.parse(init.body as string)
expect(payload.bindings['1']).toEqual({ type: 'TEXT', value: JSON.stringify({ id: 'a' }) })
expect(payload.bindings['2']).toEqual({ type: 'TEXT', value: JSON.stringify({ id: 'b' }) })
await session.close()
})
it('retries the POST on 5xx and succeeds on the next attempt', async () => {
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 503 }))
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
const session = snowflakeDestination.openSession({ config, credentials })
await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
expect(fetchMock).toHaveBeenCalledTimes(2)
await session.close()
})
it('honors Retry-After (delta seconds) on 429 before retrying', async () => {
fetchMock.mockResolvedValueOnce(
new Response('slow down', { status: 429, headers: { 'Retry-After': '1' } })
)
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }))
const session = snowflakeDestination.openSession({ config, credentials })
const start = Date.now()
await session.deliver({
body: Buffer.from(`${JSON.stringify({ x: 1 })}\n`),
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
const elapsed = Date.now() - start
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(elapsed).toBeGreaterThanOrEqual(900)
await session.close()
})
it('throws a clear error when a binding exceeds the 16 MiB VARIANT limit', async () => {
const session = snowflakeDestination.openSession({ config, credentials })
const huge = `"${'a'.repeat(16 * 1024 * 1024 + 1)}"`
const body = Buffer.from(`${huge}\n`, 'utf8')
await expect(
session.deliver({
body,
contentType: 'application/x-ndjson',
metadata: meta,
signal: new AbortController().signal,
})
).rejects.toThrow(/16 MB/)
expect(fetchMock).not.toHaveBeenCalled()
await session.close()
})
it('test() runs SELECT 1', async () => {
await snowflakeDestination.test!({
config,
credentials,
signal: new AbortController().signal,
})
const init = fetchMock.mock.calls[0]?.[1] as RequestInit
const payload = JSON.parse(init.body as string)
expect(payload.statement).toBe('SELECT 1')
})
})
@@ -0,0 +1,515 @@
import { createHash, createPublicKey } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { importPKCS8, SignJWT } from 'jose'
import { z } from 'zod'
import { sleepUntilAborted } from '@/lib/data-drains/destinations/utils'
import type { DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainSnowflakeDestination')
/**
* Snowflake account identifier formats (https://docs.snowflake.com/en/user-guide/admin-account-identifier):
* - Org-account: `<orgname>-<acctname>` — alphanumerics/underscore, hyphen-separated, no dots.
* - Legacy account locator: `<locator>` or `<locator>.<region>[.<cloud>]` — dots allowed.
*/
const ACCOUNT_ORG_RE = /^[A-Za-z0-9][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)+$/
/**
* First segment allows hyphens so org-account identifiers (`<orgname>-<acctname>`)
* carrying a legacy region/cloud suffix (e.g. `myorg-acct.us-east-1.aws`) match.
* `normalizeAccountForJwt` strips the dotted suffix for JWT `iss`/`sub`.
*/
const ACCOUNT_LOCATOR_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*){0,2}$/
function isValidAccount(v: string): boolean {
return ACCOUNT_ORG_RE.test(v) || ACCOUNT_LOCATOR_RE.test(v)
}
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,254}$/
/** JWT lifetime; Snowflake caps server-side enforcement at 60 minutes regardless of `exp`. */
const JWT_LIFETIME_SECONDS = 55 * 60
/** Safety margin (in seconds) subtracted from the JWT exp when caching. */
const JWT_CACHE_SAFETY_MARGIN_SECONDS = 300
const PER_ATTEMPT_TIMEOUT_MS = 60_000
const POLL_INITIAL_INTERVAL_MS = 500
const POLL_MAX_INTERVAL_MS = 5_000
const POLL_DEADLINE_MS = 10 * 60_000
/**
* Cap on consecutive failed poll attempts (network errors or retryable HTTP
* statuses). Independent of the 10-minute wall-clock deadline so that
* persistent failures surface in seconds, not minutes — matches the
* MAX_ATTEMPTS shape used by `executeStatement`. Reset to 0 on a successful
* 202 (still-executing) response.
*/
const POLL_MAX_CONSECUTIVE_RETRIES = 8
/** Maximum number of attempts (including the initial attempt) for retryable POST failures. */
const EXECUTE_MAX_ATTEMPTS = 3
const EXECUTE_RETRY_BASE_DELAY_MS = 500
const EXECUTE_RETRY_MAX_DELAY_MS = 5_000
/** Conservative pre-2025_03 BCR VARIANT max size (16 MiB) so the same value works on every account. */
const VARIANT_MAX_BYTES = 16 * 1024 * 1024
/** Server-side statement execution timeout (seconds) sent in the SQL API request body. */
const SQL_API_TIMEOUT_SECONDS = 600
/**
* Snowflake JWT `iss`/`sub` require the bare account identifier without any
* region/cloud suffix. For account-locator format `xy12345.us-east-1.aws`,
* only `XY12345` is valid; for org-account format `myorg-acct.us-east-1`,
* only `MYORG-ACCT` is valid. Strip everything after the first dot.
*/
function normalizeAccountForJwt(account: string): string {
const dot = account.indexOf('.')
return (dot === -1 ? account : account.slice(0, dot)).toUpperCase()
}
const snowflakeConfigSchema = z.object({
/**
* Snowflake account identifier. Accepted formats:
* - Org-account (preferred): `<orgname>-<acctname>` (no dots), e.g. `myorg-acct`
* - Account locator: `<locator>` (no dots), e.g. `xy12345`
* - Legacy regional locator: `<locator>.<region>.<cloud>`, e.g. `xy12345.us-east-1.aws`
*
* Do not include the `.snowflakecomputing.com` suffix. Modern org-account
* identifiers must not contain dots; only legacy locator URLs use dots.
*/
account: z.string().min(3, 'account is required').refine(isValidAccount, {
message: 'account must be the Snowflake account identifier (e.g. orgname-accountname)',
}),
user: z
.string()
.min(1, 'user is required')
.refine((v) => IDENTIFIER_RE.test(v), {
message: 'user must be a valid Snowflake identifier',
}),
warehouse: z
.string()
.min(1)
.refine((v) => IDENTIFIER_RE.test(v), {
message: 'warehouse must be a valid Snowflake identifier',
}),
database: z
.string()
.min(1)
.refine((v) => IDENTIFIER_RE.test(v), {
message: 'database must be a valid Snowflake identifier',
}),
schema: z
.string()
.min(1)
.refine((v) => IDENTIFIER_RE.test(v), {
message: 'schema must be a valid Snowflake identifier',
}),
table: z
.string()
.min(1)
.refine((v) => IDENTIFIER_RE.test(v), {
message: 'table must be a valid Snowflake identifier',
}),
/** Target VARIANT column. Defaults to `DATA` (uppercase, matching Snowflake's unquoted identifier folding). */
column: z
.string()
.min(1)
.refine((v) => IDENTIFIER_RE.test(v), {
message: 'column must be a valid Snowflake identifier',
})
.optional(),
/** Optional Snowflake role to assume for the insert. */
role: z
.string()
.min(1)
.refine((v) => IDENTIFIER_RE.test(v), {
message: 'role must be a valid Snowflake identifier',
})
.optional(),
})
const snowflakeCredentialsSchema = z.object({
/** PKCS8-encoded RSA private key (PEM). The matching public key must be registered on the user. */
privateKey: z.string().min(1, 'privateKey is required'),
})
export type SnowflakeDestinationConfig = z.infer<typeof snowflakeConfigSchema>
export type SnowflakeDestinationCredentials = z.infer<typeof snowflakeCredentialsSchema>
/**
* Computes the SHA256:<base64> fingerprint of the public key derived from the
* given private key. Snowflake encodes this in the JWT issuer claim so the
* server can match the signature against the registered public key.
* Reference: https://docs.snowflake.com/en/developer-guide/sql-api/authenticating
*/
function computePublicKeyFingerprint(privateKeyPem: string): string {
const publicKey = createPublicKey({ key: privateKeyPem, format: 'pem' })
const spkiDer = publicKey.export({ type: 'spki', format: 'der' })
return `SHA256:${createHash('sha256').update(spkiDer).digest('base64')}`
}
interface JwtCacheEntry {
token: string
expiresAt: number
}
async function buildJwt(
account: string,
user: string,
privateKeyPem: string
): Promise<JwtCacheEntry> {
const fingerprint = computePublicKeyFingerprint(privateKeyPem)
const accountForJwt = normalizeAccountForJwt(account)
const userUpper = user.toUpperCase()
const issuer = `${accountForJwt}.${userUpper}.${fingerprint}`
const subject = `${accountForJwt}.${userUpper}`
const now = Math.floor(Date.now() / 1000)
const exp = now + JWT_LIFETIME_SECONDS
let privateKey: Awaited<ReturnType<typeof importPKCS8>>
try {
privateKey = await importPKCS8(privateKeyPem, 'RS256')
} catch (error) {
throw new Error(
`privateKey must be an unencrypted PKCS#8 PEM (-----BEGIN PRIVATE KEY-----). ` +
`Convert PKCS#1 with: openssl pkcs8 -topk8 -nocrypt -in rsa.pem -out pkcs8.pem. ` +
`Decrypt encrypted PEMs first. Underlying error: ${toError(error).message}`
)
}
const token = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(issuer)
.setSubject(subject)
.setIssuedAt(now)
.setExpirationTime(exp)
.sign(privateKey)
return { token, expiresAt: exp - JWT_CACHE_SAFETY_MARGIN_SECONDS }
}
/**
* Quotes a Snowflake identifier so that whatever case the user typed is
* preserved exactly. Without quoting, Snowflake folds unquoted identifiers
* to uppercase, which silently breaks any table whose canonical name was
* created with quoted mixed-case. Embedded `"` is escaped as `""`.
*/
function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`
}
function buildStatement(config: SnowflakeDestinationConfig, rowCount: number): string {
const column = quoteIdentifier(config.column ?? 'DATA')
const target = `${quoteIdentifier(config.database)}.${quoteIdentifier(config.schema)}.${quoteIdentifier(config.table)}`
const placeholders = Array.from({ length: rowCount }, () => '(PARSE_JSON(?))').join(', ')
return `INSERT INTO ${target} (${column}) VALUES ${placeholders}`
}
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || (status >= 500 && status <= 599)
}
interface ExecuteInput {
config: SnowflakeDestinationConfig
getJwt: () => Promise<string>
statement: string
bindings: string[]
signal: AbortSignal
}
async function executeStatement(input: ExecuteInput): Promise<void> {
for (const value of input.bindings) {
const bytes = Buffer.byteLength(value, 'utf8')
if (bytes > VARIANT_MAX_BYTES) {
throw new Error(
`Snowflake VARIANT value exceeds 16 MB limit (got ${bytes} bytes); split the row before delivery`
)
}
}
const baseUrl = `https://${input.config.account}.snowflakecomputing.com/api/v2/statements`
const bindings: Record<string, { type: 'TEXT'; value: string }> = {}
input.bindings.forEach((value, index) => {
bindings[(index + 1).toString()] = { type: 'TEXT', value }
})
const body = {
statement: input.statement,
timeout: SQL_API_TIMEOUT_SECONDS,
warehouse: input.config.warehouse,
role: input.config.role,
bindings,
}
const serializedBody = JSON.stringify(body)
/** Stable per-request UUID enables idempotent retries via `retry=true` on subsequent attempts. */
const requestId = generateId()
let lastError: unknown
for (let attempt = 1; attempt <= EXECUTE_MAX_ATTEMPTS; attempt++) {
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
/** Acquire JWT before starting the per-attempt timer so token signing doesn't eat the network budget (mirrors pollStatement). */
const jwt = await input.getJwt()
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
const params = new URLSearchParams({ requestId })
if (attempt > 1) params.set('retry', 'true')
const url = `${baseUrl}?${params.toString()}`
let response: Response
try {
response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Snowflake-Authorization-Token-Type': 'KEYPAIR_JWT',
'User-Agent': 'sim-data-drain/1.0',
},
body: serializedBody,
signal: perAttempt,
})
} catch (error) {
lastError = error
logger.warn('Snowflake request failed', {
attempt,
error: toError(error).message,
})
if (input.signal.aborted || attempt === EXECUTE_MAX_ATTEMPTS) throw error
await sleepUntilAborted(
backoffWithJitter(attempt, null, {
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
}),
input.signal
)
continue
}
if (response.status === 202) {
const json = (await response.json().catch(() => ({}))) as { statementHandle?: string }
if (!json.statementHandle) {
throw new Error('Snowflake returned 202 without a statementHandle')
}
await pollStatement({
account: input.config.account,
getJwt: input.getJwt,
handle: json.statementHandle,
signal: input.signal,
})
return
}
if (response.ok) {
/**
* Synchronous completions return 200 — same statement-level error envelope as
* the polled 200 path, so check `sqlState` here too instead of silently passing
* failures. Consuming the body also lets undici reuse the socket.
*/
const completion = (await response.json().catch(() => ({}))) as {
code?: string
sqlState?: string
message?: string
}
if (completion.sqlState && completion.sqlState !== '00000') {
throw new Error(
`Snowflake statement failed (sqlState ${completion.sqlState}${completion.code ? `, code ${completion.code}` : ''}): ${completion.message ?? ''}`
)
}
return
}
const text = await response.text().catch(() => '')
const error = new Error(`Snowflake responded with HTTP ${response.status}: ${text}`)
if (!isRetryableStatus(response.status) || attempt === EXECUTE_MAX_ATTEMPTS) throw error
lastError = error
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'))
const delay = backoffWithJitter(attempt, retryAfterMs, {
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
})
logger.warn('Snowflake request retrying after retryable status', {
attempt,
status: response.status,
delayMs: delay,
})
await sleepUntilAborted(delay, input.signal)
}
throw lastError ?? new Error('Snowflake request failed after retries')
}
interface PollInput {
account: string
/** Thunk so long polls (past 55min) refresh the JWT instead of dying with 401. */
getJwt: () => Promise<string>
handle: string
signal: AbortSignal
}
/** Snowflake returns 202 while still executing and 200 on completion (async statement-handle semantics). */
async function pollStatement(input: PollInput): Promise<void> {
const url = `https://${input.account}.snowflakecomputing.com/api/v2/statements/${encodeURIComponent(input.handle)}`
const deadline = Date.now() + POLL_DEADLINE_MS
let interval = POLL_INITIAL_INTERVAL_MS
let skipIntervalSleep = true
let retryAttempt = 0
while (Date.now() < deadline) {
if (input.signal.aborted) throw input.signal.reason ?? new Error('Aborted')
if (!skipIntervalSleep) {
await sleepUntilAborted(interval, input.signal)
}
skipIntervalSleep = false
const jwt = await input.getJwt()
const perAttempt = AbortSignal.any([input.signal, AbortSignal.timeout(PER_ATTEMPT_TIMEOUT_MS)])
let response: Response
try {
response = await fetch(url, {
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/json',
'X-Snowflake-Authorization-Token-Type': 'KEYPAIR_JWT',
},
signal: perAttempt,
})
} catch (error) {
if (input.signal.aborted) throw error
retryAttempt++
if (retryAttempt > POLL_MAX_CONSECUTIVE_RETRIES) throw error
const delay = backoffWithJitter(retryAttempt, null, {
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
})
logger.warn('Snowflake poll request failed, retrying', {
attempt: retryAttempt,
delayMs: delay,
error: toError(error).message,
})
await sleepUntilAborted(delay, input.signal)
skipIntervalSleep = true
continue
}
if (response.status === 202) {
/** Drain the body so undici can return the socket to the keep-alive pool between polls. */
await response.text().catch(() => '')
retryAttempt = 0
interval = Math.min(interval * 2, POLL_MAX_INTERVAL_MS)
continue
}
if (isRetryableStatus(response.status)) {
retryAttempt++
if (retryAttempt > POLL_MAX_CONSECUTIVE_RETRIES) {
/** Drain the body so undici can return the socket to the keep-alive pool. */
const text = await response.text().catch(() => '')
throw new Error(
`Snowflake poll failed after ${POLL_MAX_CONSECUTIVE_RETRIES} consecutive retries (HTTP ${response.status}): ${text}`
)
}
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'))
const delay = backoffWithJitter(retryAttempt, retryAfterMs, {
baseMs: EXECUTE_RETRY_BASE_DELAY_MS,
maxMs: EXECUTE_RETRY_MAX_DELAY_MS,
})
logger.warn('Snowflake poll retrying after retryable status', {
attempt: retryAttempt,
status: response.status,
delayMs: delay,
})
/** Drain the body so undici can return the socket to the keep-alive pool between retries. */
await response.text().catch(() => '')
await sleepUntilAborted(delay, input.signal)
skipIntervalSleep = true
continue
}
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`Snowflake poll failed (HTTP ${response.status}): ${text}`)
}
/**
* Snowflake SQL API can return 200 with a statement-level error envelope
* (`code` / `sqlState` / `message`). Successful completions return
* `code === "090001"` ("statement executed successfully") or omit `code`,
* while statement errors come back with codes like `"002032"` and
* a populated `sqlState`. Treat anything with a `sqlState` as a failure.
*/
const completion = (await response.json().catch(() => ({}))) as {
code?: string
sqlState?: string
message?: string
}
if (completion.sqlState && completion.sqlState !== '00000') {
throw new Error(
`Snowflake statement failed (sqlState ${completion.sqlState}${completion.code ? `, code ${completion.code}` : ''}): ${completion.message ?? ''}`
)
}
return
}
throw new Error('Snowflake statement did not complete within the polling deadline')
}
export const snowflakeDestination: DrainDestination<
SnowflakeDestinationConfig,
SnowflakeDestinationCredentials
> = {
type: 'snowflake',
displayName: 'Snowflake',
configSchema: snowflakeConfigSchema,
credentialsSchema: snowflakeCredentialsSchema,
async test({ config, credentials, signal }) {
let cached: JwtCacheEntry | null = null
async function getJwt(): Promise<string> {
const now = Math.floor(Date.now() / 1000)
if (cached && cached.expiresAt > now) return cached.token
cached = await buildJwt(config.account, config.user, credentials.privateKey)
return cached.token
}
await executeStatement({
config,
getJwt,
statement: 'SELECT 1',
bindings: [],
signal,
})
},
openSession({ config, credentials }) {
let cached: JwtCacheEntry | null = null
async function getJwt(): Promise<string> {
const now = Math.floor(Date.now() / 1000)
if (cached && cached.expiresAt > now) return cached.token
cached = await buildJwt(config.account, config.user, credentials.privateKey)
return cached.token
}
return {
async deliver({ body, metadata, signal }) {
/**
* Bind the original line bytes — not `JSON.stringify(JSON.parse(line))` —
* so JSON numbers outside the JS safe-integer range (e.g. Snowflake
* NUMBER columns past 2^53-1) survive into VARIANT intact. We still
* parse each line so a malformed payload fails fast at the runner.
*/
const text = body.toString('utf8')
const rows: string[] = []
const lines = text.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.length === 0) continue
try {
JSON.parse(line)
} catch (error) {
throw new Error(
`Snowflake NDJSON parse failed at line ${i + 1}: ${toError(error).message}`
)
}
rows.push(line)
}
if (rows.length === 0) {
return {
locator: `snowflake://${config.account}/${config.database}.${config.schema}.${config.table}#${metadata.runId}-${metadata.sequence}`,
}
}
await executeStatement({
config,
getJwt,
statement: buildStatement(config, rows.length),
bindings: rows,
signal,
})
logger.debug('Snowflake chunk delivered', {
account: config.account,
table: `${config.database}.${config.schema}.${config.table}`,
rows: rows.length,
})
return {
locator: `snowflake://${config.account}/${config.database}.${config.schema}.${config.table}#${metadata.runId}-${metadata.sequence}`,
}
},
async close() {},
}
},
}
@@ -0,0 +1,164 @@
import { toError } from '@sim/utils/errors'
import { z } from 'zod'
/**
* Sleep for `ms` milliseconds, resolving early if `signal` aborts. Used by
* destination retry/poll loops so cancelled drain runs do not hang waiting on
* a `setTimeout` that ignores the abort signal.
*/
export function sleepUntilAborted(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => {
const onAbort = () => {
clearTimeout(timeoutId)
resolve()
}
const timeoutId = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
export function normalizePrefix(raw: string | undefined): string {
if (!raw) return ''
const trimmed = raw.replace(/^\/+/, '').replace(/\/+$/, '')
return trimmed.length === 0 ? '' : `${trimmed}/`
}
export interface ObjectKeyMetadata {
drainId: string
runId: string
source: string
sequence: number
runStartedAt: Date
}
/**
* Builds a date-partitioned NDJSON object key for blob-store destinations.
* Layout: `<prefix><source>/<drainId>/<YYYY>/<MM>/<DD>/<runId>-<seq>.ndjson`.
* Partition uses the run's start time so all chunks from a run share one
* date prefix even if delivery crosses a UTC midnight boundary.
*/
export function buildObjectKey(prefix: string | undefined, metadata: ObjectKeyMetadata): string {
const partition = metadata.runStartedAt
const yyyy = partition.getUTCFullYear().toString().padStart(4, '0')
const mm = (partition.getUTCMonth() + 1).toString().padStart(2, '0')
const dd = partition.getUTCDate().toString().padStart(2, '0')
const seq = metadata.sequence.toString().padStart(5, '0')
return `${normalizePrefix(prefix)}${metadata.source}/${metadata.drainId}/${yyyy}/${mm}/${dd}/${metadata.runId}-${seq}.ndjson`
}
export interface ParsedServiceAccount {
clientEmail: string
privateKey: string
}
/**
* Parses a Google service-account JSON key, returning the only two fields
* that destinations need (client email + private key). Shared by GCS and
* BigQuery so a fix in one place applies to both.
*/
export function parseServiceAccount(json: string): ParsedServiceAccount {
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch (error) {
throw new Error(`serviceAccountJson is not valid JSON: ${toError(error).message}`)
}
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('serviceAccountJson must be a JSON object')
}
const obj = parsed as Record<string, unknown>
const clientEmail = obj.client_email
const privateKey = obj.private_key
if (typeof clientEmail !== 'string' || clientEmail.length === 0) {
throw new Error('serviceAccountJson is missing client_email')
}
if (typeof privateKey !== 'string' || privateKey.length === 0) {
throw new Error('serviceAccountJson is missing private_key')
}
return { clientEmail, privateKey }
}
/**
* Zod `superRefine` helper that validates a service-account JSON key string
* is parseable and carries `client_email` + `private_key`. Used by both
* `gcsCredentialsSchema` and `bigqueryCredentialsSchema`.
*/
export interface ParseNdjsonObjectsOptions {
/** When true, throw if a parsed value is not a plain object. */
requireObject?: boolean
}
/**
* Parses an NDJSON buffer into per-row JSON values. Error messages use
* 1-indexed line numbers so they line up with how editors and `Content-Range`
* headers reference NDJSON payloads.
*/
export function parseNdjsonObjects(
body: Buffer,
options: ParseNdjsonObjectsOptions = {}
): unknown[] {
const text = body.toString('utf8')
const rows: unknown[] = []
const lines = text.split(/\r?\n/)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.length === 0) continue
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch (error) {
throw new Error(`NDJSON parse failed at line ${i + 1}: ${toError(error).message}`)
}
if (options.requireObject) {
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`NDJSON row at line ${i + 1} is not an object`)
}
}
rows.push(parsed)
}
return rows
}
export function refineServiceAccountJson(
value: { serviceAccountJson: string },
ctx: z.RefinementCtx
): void {
let parsed: unknown
try {
parsed = JSON.parse(value.serviceAccountJson)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['serviceAccountJson'],
message: 'serviceAccountJson must be valid JSON',
})
return
}
if (typeof parsed !== 'object' || parsed === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['serviceAccountJson'],
message: 'serviceAccountJson must be a JSON object',
})
return
}
const obj = parsed as Record<string, unknown>
if (typeof obj.client_email !== 'string' || obj.client_email.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['serviceAccountJson'],
message: 'serviceAccountJson is missing client_email',
})
}
if (typeof obj.private_key !== 'string' || obj.private_key.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['serviceAccountJson'],
message: 'serviceAccountJson is missing private_key',
})
}
}
@@ -0,0 +1,176 @@
/**
* @vitest-environment node
*/
import { createHmac } from 'node:crypto'
import { inputValidationMock, inputValidationMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
import { webhookDestination } from '@/lib/data-drains/destinations/webhook'
const config = { url: 'https://example.com/hook' }
const credentials = { signingSecret: 'super-secret-key' }
const metadata = {
drainId: 'd1',
runId: 'r1',
source: 'workflow_logs' as const,
sequence: 3,
rowCount: 5,
}
function mockPinnedFetchOnce(response: { ok: boolean; status: number; headers?: Headers }) {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce({
ok: response.ok,
status: response.status,
statusText: '',
headers: response.headers ?? new Headers(),
text: async () => '',
json: async () => ({}),
arrayBuffer: async () => new ArrayBuffer(0),
})
}
beforeEach(() => {
vi.clearAllMocks()
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: '93.184.216.34',
originalHostname: 'example.com',
})
})
describe('webhookDestination openSession', () => {
it('signs the body with HMAC-SHA256 over `<ts>.<body>`', async () => {
mockPinnedFetchOnce({ ok: true, status: 200 })
const session = webhookDestination.openSession({ config, credentials })
const body = Buffer.from('{"id":1}\n', 'utf8')
await session.deliver({
body,
contentType: 'application/x-ndjson',
metadata,
signal: new AbortController().signal,
})
const call = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
const [calledUrl, pinnedIP, init] = call
expect(calledUrl).toBe('https://example.com/hook')
expect(pinnedIP).toBe('93.184.216.34')
const headers = init.headers as Record<string, string>
expect(headers['Content-Type']).toBe('application/x-ndjson')
expect(headers['X-Sim-Drain-Id']).toBe('d1')
expect(headers['X-Sim-Run-Id']).toBe('r1')
expect(headers['X-Sim-Sequence']).toBe('3')
expect(headers['Idempotency-Key']).toBe('r1-3')
const sig = headers['X-Sim-Signature']
const tsPart = sig.match(/t=(\d+)/)![1]
const v1Part = sig.match(/v1=([0-9a-f]+)/)![1]
const expected = createHmac('sha256', credentials.signingSecret)
.update(`${tsPart}.`)
.update(body)
.digest('hex')
expect(v1Part).toBe(expected)
await session.close()
})
it('retries on 5xx and succeeds', async () => {
mockPinnedFetchOnce({ ok: false, status: 503 })
mockPinnedFetchOnce({ ok: true, status: 200 })
vi.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void) => {
fn()
return 0 as unknown as NodeJS.Timeout
}) as never)
const session = webhookDestination.openSession({ config, credentials })
const result = await session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata,
signal: new AbortController().signal,
})
expect(result.locator).toContain('https://example.com/hook')
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(2)
})
it('does not retry on 4xx (other than 408/429)', async () => {
mockPinnedFetchOnce({ ok: false, status: 401 })
const session = webhookDestination.openSession({ config, credentials })
await expect(
session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata,
signal: new AbortController().signal,
})
).rejects.toThrow(/HTTP 401/)
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)
})
it('rejects when DNS resolves to a blocked IP', async () => {
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValueOnce({
isValid: false,
error: 'url resolves to a blocked IP address',
})
const session = webhookDestination.openSession({ config, credentials })
await expect(
session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata,
signal: new AbortController().signal,
})
).rejects.toThrow(/blocked IP/)
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
})
it('reuses the same pinned IP across deliveries (no DNS rebinding window)', async () => {
mockPinnedFetchOnce({ ok: true, status: 200 })
mockPinnedFetchOnce({ ok: true, status: 200 })
const session = webhookDestination.openSession({ config, credentials })
const signal = new AbortController().signal
await session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata,
signal,
})
await session.deliver({
body: Buffer.from('y'),
contentType: 'application/x-ndjson',
metadata: { ...metadata, sequence: 4 },
signal,
})
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1)
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
expect(calls[0][1]).toBe('93.184.216.34')
expect(calls[1][1]).toBe('93.184.216.34')
})
it('rejects every header buildHeaders writes when reused as signatureHeader (drift guard)', async () => {
mockPinnedFetchOnce({ ok: true, status: 200 })
const session = webhookDestination.openSession({ config, credentials })
await session.deliver({
body: Buffer.from('x'),
contentType: 'application/x-ndjson',
metadata,
signal: new AbortController().signal,
})
const init = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0][2]
const writtenHeaders = Object.keys(init.headers as Record<string, string>)
for (const name of writtenHeaders) {
const result = webhookDestination.configSchema.safeParse({
url: 'https://example.com/hook',
signatureHeader: name,
})
expect(
result.success,
`expected signatureHeader="${name}" to be rejected (it is written by buildHeaders)`
).toBe(false)
}
})
})
@@ -0,0 +1,246 @@
import { createHmac } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry'
import { z } from 'zod'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { sleepUntilAborted } from '@/lib/data-drains/destinations/utils'
import type { DeliveryMetadata, DrainDestination } from '@/lib/data-drains/types'
const logger = createLogger('DataDrainWebhookDestination')
/** Initial attempt + 3 retries — 500ms/1s/2s backoff sequence. */
const MAX_ATTEMPTS = 4
const PER_ATTEMPT_TIMEOUT_MS = 30_000
/** Cap responder reply so a misbehaving receiver can't OOM the runner. */
const MAX_RESPONSE_BYTES = 256 * 1024
const SIGNATURE_VERSION = 'v1'
const USER_AGENT = 'Sim-DataDrain/1.0'
/**
* Headers `buildHeaders` emits. Callers cannot override these via
* `signatureHeader`. Keep in sync with `buildHeaders` — the drift-guard test
* enforces this by parsing the schema against every key the function writes.
*/
const RESERVED_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',
])
/** CR/LF/NUL would let a bearer token smuggle additional response headers. */
const HEADER_INJECTION_PATTERN = /[\r\n\0]/
async function resolvePublicTarget(url: string): Promise<string> {
const result = await validateUrlWithDNS(url, 'url')
if (!result.isValid || !result.resolvedIP) {
throw new Error(result.error ?? 'Webhook URL failed SSRF validation')
}
return result.resolvedIP
}
const webhookConfigSchema = 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_SIGNATURE_HEADER_NAMES.has(value.toLowerCase()), {
message: 'signatureHeader cannot reuse a reserved Sim header name',
})
.refine((value) => !HEADER_INJECTION_PATTERN.test(value) && /^[A-Za-z0-9\-_]+$/.test(value), {
message: 'signatureHeader must contain only letters, digits, hyphens, and underscores',
})
.optional(),
})
const webhookCredentialsSchema = 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) => !HEADER_INJECTION_PATTERN.test(value), {
message: 'bearerToken cannot contain CR, LF, or NUL characters',
})
.optional(),
})
export type WebhookDestinationConfig = z.infer<typeof webhookConfigSchema>
export type WebhookDestinationCredentials = z.infer<typeof webhookCredentialsSchema>
/**
* Stripe-style signature: HMAC-SHA256 over `${unixSeconds}.${body}` rendered as
* `t=<unixSeconds>,v1=<hex>`. Verifiers reject stale timestamps (~5 min skew)
* to block replay; we re-sign per attempt so long backoffs don't fall outside
* that window.
*/
function sign(body: Buffer, secret: string, timestamp: number): string {
const hmac = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')
return `t=${timestamp},${SIGNATURE_VERSION}=${hmac}`
}
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500
}
function buildHeaders(input: {
config: WebhookDestinationConfig
credentials: WebhookDestinationCredentials
body: Buffer
contentType: string
metadata?: DeliveryMetadata
isProbe?: boolean
}): Record<string, string> {
const timestamp = Math.floor(Date.now() / 1000)
const headers: Record<string, string> = {
'Content-Type': input.contentType,
'User-Agent': USER_AGENT,
'X-Sim-Timestamp': timestamp.toString(),
'X-Sim-Signature-Version': SIGNATURE_VERSION,
[input.config.signatureHeader ?? 'X-Sim-Signature']: sign(
input.body,
input.credentials.signingSecret,
timestamp
),
}
if (input.metadata) {
headers['X-Sim-Drain-Id'] = input.metadata.drainId
headers['X-Sim-Run-Id'] = input.metadata.runId
headers['X-Sim-Source'] = input.metadata.source
headers['X-Sim-Sequence'] = input.metadata.sequence.toString()
headers['X-Sim-Row-Count'] = input.metadata.rowCount.toString()
// Stable across retries of the same chunk so receivers can dedupe.
headers['Idempotency-Key'] = `${input.metadata.runId}-${input.metadata.sequence}`
}
if (input.isProbe) {
headers['X-Sim-Probe'] = '1'
}
if (input.credentials.bearerToken) {
headers.Authorization = `Bearer ${input.credentials.bearerToken}`
}
return headers
}
export const webhookDestination: DrainDestination<
WebhookDestinationConfig,
WebhookDestinationCredentials
> = {
type: 'webhook',
displayName: 'HTTPS Webhook',
configSchema: webhookConfigSchema,
credentialsSchema: webhookCredentialsSchema,
async test({ config, credentials, signal }) {
const resolvedIP = await resolvePublicTarget(config.url)
const probe = Buffer.from('{"sim":"connection-test"}\n', 'utf8')
const headers = buildHeaders({
config,
credentials,
body: probe,
contentType: 'application/x-ndjson',
isProbe: true,
})
const response = await secureFetchWithPinnedIP(config.url, resolvedIP, {
method: 'POST',
body: new Uint8Array(probe),
headers,
signal,
timeout: PER_ATTEMPT_TIMEOUT_MS,
maxResponseBytes: MAX_RESPONSE_BYTES,
})
if (!response.ok) {
throw new Error(`Webhook probe failed: HTTP ${response.status}`)
}
},
openSession({ config, credentials }) {
let resolvedIP: string | null = null
return {
async deliver({ body, contentType, metadata, signal }) {
// Resolve once per session and pin across retries to defeat DNS rebinding (TOCTOU).
if (resolvedIP === null) {
resolvedIP = await resolvePublicTarget(config.url)
}
let lastError: unknown
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (signal.aborted) throw signal.reason ?? new Error('Aborted')
const headers = buildHeaders({ config, credentials, body, contentType, metadata })
let retryAfterMs: number | null = null
let response: Awaited<ReturnType<typeof secureFetchWithPinnedIP>> | undefined
try {
response = await secureFetchWithPinnedIP(config.url, resolvedIP, {
method: 'POST',
body: new Uint8Array(body),
headers,
signal,
timeout: PER_ATTEMPT_TIMEOUT_MS,
maxResponseBytes: MAX_RESPONSE_BYTES,
})
} catch (error) {
lastError = error
logger.debug('Webhook delivery attempt failed', {
url: config.url,
attempt,
error: toError(error).message,
})
}
if (response) {
if (response.ok) {
const requestId =
response.headers.get('x-request-id') ??
response.headers.get('x-amzn-trace-id') ??
null
logger.debug('Webhook chunk delivered', {
url: config.url,
attempt,
status: response.status,
bytes: body.byteLength,
})
return {
locator: requestId
? `${config.url}#${metadata.runId}-${metadata.sequence}@${requestId}`
: `${config.url}#${metadata.runId}-${metadata.sequence}`,
}
}
if (!isRetryableStatus(response.status)) {
throw new Error(`Webhook responded with HTTP ${response.status}`)
}
lastError = new Error(`Webhook responded with HTTP ${response.status}`)
retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
}
if (attempt < MAX_ATTEMPTS) {
await sleepUntilAborted(backoffWithJitter(attempt, retryAfterMs), signal)
}
}
throw lastError instanceof Error
? lastError
: new Error('Webhook delivery failed after retries')
},
async close() {},
}
},
}