refactor(tiktok): align webhook routing with shared dispatcher (#6261)

* refactor(tiktok): route webhooks by account key

* refactor(tiktok): remove custom webhook ingress jobs

* fix(tiktok): constrain webhook routing migration

* test(tiktok): remove redundant assertions

* fix(tiktok): preserve routing backfill state

* fix(tiktok): preserve legacy webhook routing during rollout

* fix(tiktok): preserve webhook delivery guarantees

* refactor(tiktok): remove rollout compatibility

* refactor(tiktok): remove unused webhook backfill

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
This commit is contained in:
Bill Leoutsakos
2026-08-08 15:33:33 -07:00
committed by GitHub
co-authored by Bill Leoutsakos
parent 9883543eb2
commit fb6e18f5db
19 changed files with 19056 additions and 743 deletions
+69 -20
View File
@@ -7,13 +7,17 @@ import { requestUtilsMockFns, resetEnvMock, setEnv } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockEnqueueTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({
mockEnqueueTikTokWebhookIngress: vi.fn(),
mockRelease: vi.fn(),
}))
const { mockDispatchResolvedWebhookTarget, mockFindWebhooksByRoutingKey, mockRelease } = vi.hoisted(
() => ({
mockDispatchResolvedWebhookTarget: vi.fn(),
mockFindWebhooksByRoutingKey: vi.fn(),
mockRelease: vi.fn(),
})
)
vi.mock('@/background/tiktok-webhook-ingress', () => ({
enqueueTikTokWebhookIngress: mockEnqueueTikTokWebhookIngress,
vi.mock('@/lib/webhooks/processor', () => ({
dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget,
findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey,
}))
vi.mock('@/lib/core/admission/gate', () => ({
@@ -29,12 +33,17 @@ vi.mock('@/lib/core/utils/with-route-handler', () => ({
import { POST } from '@/app/api/webhooks/tiktok/route'
function signedRequest(overrides?: { clientKey?: string }): NextRequest {
const target = (id: string) => ({
webhook: { id, path: null, provider: 'tiktok' },
workflow: { id: `workflow-${id}` },
})
function signedRequest(overrides?: { clientKey?: string; userOpenId?: string }): NextRequest {
const body = JSON.stringify({
client_key: overrides?.clientKey ?? 'client-key',
event: 'post.publish.complete',
create_time: 1_725_000_000,
user_openid: 'act.user',
user_openid: overrides?.userOpenId ?? 'act.user',
content: '{"publish_id":"publish-1"}',
})
const timestamp = String(Math.floor(Date.now() / 1000))
@@ -53,12 +62,13 @@ function signedRequest(overrides?: { clientKey?: string }): NextRequest {
})
}
describe('TikTok webhook ingress route', () => {
describe('TikTok app webhook route', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnv({ TIKTOK_CLIENT_ID: 'client-key', TIKTOK_CLIENT_SECRET: 'client-secret' })
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1')
mockEnqueueTikTokWebhookIngress.mockResolvedValue('ingress-job-1')
mockFindWebhooksByRoutingKey.mockResolvedValue([])
mockDispatchResolvedWebhookTarget.mockResolvedValue({ outcome: 'queued', reason: 'queued' })
})
afterAll(() => {
@@ -66,25 +76,64 @@ describe('TikTok webhook ingress route', () => {
requestUtilsMockFns.mockGenerateRequestId.mockReset()
})
it('returns 200 only after the verified delivery is accepted by the job queue', async () => {
const response = await POST(signedRequest())
it('routes a verified delivery by user_openid on the TikTok provider', async () => {
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1')])
const response = await POST(signedRequest({ userOpenId: 'user-open-id' }))
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ ok: true })
expect(mockEnqueueTikTokWebhookIngress).toHaveBeenCalledWith(
expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith('user-open-id', 'request-1', 'tiktok')
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledWith(
expect.objectContaining({ id: 'webhook-1' }),
expect.objectContaining({ id: 'workflow-webhook-1' }),
expect.objectContaining({ user_openid: 'user-open-id' }),
expect.any(NextRequest),
expect.objectContaining({
envelope: expect.objectContaining({
client_key: 'client-key',
user_openid: 'act.user',
}),
requestId: 'request-1',
triggerTimestampMs: 1_725_000_000_000,
})
)
expect(mockRelease).toHaveBeenCalledOnce()
})
it('returns 503 when durable acceptance fails so TikTok retries', async () => {
mockEnqueueTikTokWebhookIngress.mockRejectedValue(new Error('queue unavailable'))
it('acknowledges a verified delivery when no workflow targets match', async () => {
const response = await POST(signedRequest())
expect(response.status).toBe(200)
expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
})
it('dispatches matching workflows sequentially', async () => {
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1'), target('webhook-2')])
const order: string[] = []
mockDispatchResolvedWebhookTarget.mockImplementation(async (webhook: { id: string }) => {
order.push(`start:${webhook.id}`)
await Promise.resolve()
order.push(`end:${webhook.id}`)
return { outcome: 'queued', reason: 'queued' }
})
const response = await POST(signedRequest())
expect(response.status).toBe(200)
expect(order).toEqual(['start:webhook-1', 'end:webhook-1', 'start:webhook-2', 'end:webhook-2'])
})
it('returns a retryable response when a target cannot be dispatched', async () => {
mockFindWebhooksByRoutingKey.mockResolvedValue([target('webhook-1')])
mockDispatchResolvedWebhookTarget.mockResolvedValue({
outcome: 'failed',
reason: 'queue-failed',
})
const response = await POST(signedRequest())
expect(response.status).toBe(503)
})
it('returns 503 when target lookup fails', async () => {
mockFindWebhooksByRoutingKey.mockRejectedValue(new Error('database unavailable'))
const response = await POST(signedRequest())
@@ -96,6 +145,6 @@ describe('TikTok webhook ingress route', () => {
const response = await POST(signedRequest({ clientKey: 'other-client-key' }))
expect(response.status).toBe(401)
expect(mockEnqueueTikTokWebhookIngress).not.toHaveBeenCalled()
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
})
})
+23 -17
View File
@@ -12,13 +12,10 @@ import {
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
import { dispatchResolvedWebhookTarget, findWebhooksByRoutingKey } from '@/lib/webhooks/processor'
import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok'
import {
enqueueTikTokWebhookIngress,
type TikTokWebhookIngressPayload,
} from '@/background/tiktok-webhook-ingress'
const logger = createLogger('TikTokWebhookIngress')
const logger = createLogger('TikTokAppWebhookAPI')
const TIKTOK_BODY_LABEL = 'TikTok webhook body'
@@ -38,7 +35,7 @@ async function readTikTokBody(req: Request): Promise<string> {
/**
* App-level TikTok webhook Callback URL.
* Portal: `{APP_URL}/api/webhooks/tiktok` (e.g. https://www.sim.ai/api/webhooks/tiktok).
* Verifies TikTok-Signature and durably accepts the delivery before background target fanout.
* Verifies TikTok-Signature and routes the delivery by TikTok `user_openid`.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const ticket = tryAdmit()
@@ -96,25 +93,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload: TikTokWebhookIngressPayload = {
envelope,
headers: {
'content-type': request.headers.get('content-type') ?? 'application/json',
},
requestId,
receivedAt,
const webhooks = await findWebhooksByRoutingKey(envelope.user_openid, requestId, 'tiktok')
let dispatched = 0
let failed = 0
for (const { webhook, workflow } of webhooks) {
const result = await dispatchResolvedWebhookTarget(webhook, workflow, envelope, request, {
requestId,
receivedAt,
triggerTimestampMs: envelope.create_time * 1000,
})
if (result.outcome === 'queued') dispatched += 1
if (result.outcome === 'failed') failed += 1
}
const jobId = await enqueueTikTokWebhookIngress(payload)
logger.info(`[${requestId}] Accepted TikTok webhook delivery`, {
logger.info(`[${requestId}] Processed TikTok webhook delivery`, {
dispatched,
failed,
event: envelope.event,
jobId,
targetCount: webhooks.length,
userOpenIdPrefix: envelope.user_openid.slice(0, 12),
})
if (failed > 0) {
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
}
return NextResponse.json({ ok: true })
} catch (error) {
logger.error(`[${requestId}] TikTok webhook ingress error`, {
logger.error(`[${requestId}] TikTok webhook processing error`, {
error: getErrorMessage(error, 'Unknown error'),
})
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
@@ -1,173 +0,0 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDispatchResolvedWebhookTarget, mockEnqueue, mockFindTikTokWebhookTargetPage } =
vi.hoisted(() => ({
mockDispatchResolvedWebhookTarget: vi.fn(),
mockEnqueue: vi.fn(),
mockFindTikTokWebhookTargetPage: vi.fn(),
}))
vi.mock('@trigger.dev/sdk', () => ({
task: vi.fn((config: unknown) => config),
}))
vi.mock('@/lib/webhooks/processor', () => ({
dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget,
}))
vi.mock('@/background/tiktok-webhook-targets', () => ({
findTikTokWebhookTargetPage: mockFindTikTokWebhookTargetPage,
}))
vi.mock('@/lib/core/async-jobs', () => ({
getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })),
}))
import {
enqueueTikTokWebhookIngress,
executeTikTokWebhookIngress,
type TikTokWebhookIngressPayload,
} from '@/background/tiktok-webhook-ingress'
const payload: TikTokWebhookIngressPayload = {
envelope: {
client_key: 'client-key',
event: 'post.publish.complete',
create_time: 1_725_000_000,
user_openid: 'act.user',
content: '{"publish_id":"publish-1"}',
},
headers: { 'content-type': 'application/json' },
requestId: 'request-1',
receivedAt: 1_725_000_000_000,
}
const firstTarget = {
webhook: { id: 'webhook-1', path: 'tiktok', provider: 'tiktok' },
workflow: { id: 'workflow-1' },
}
const secondTarget = {
webhook: { id: 'webhook-2', path: 'tiktok', provider: 'tiktok' },
workflow: { id: 'workflow-2' },
}
describe('executeTikTokWebhookIngress', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnqueue.mockResolvedValue('ingress-job-1')
})
it('acknowledges deliveries without active targets', async () => {
mockFindTikTokWebhookTargetPage.mockResolvedValue({
hasMore: false,
nextCursor: null,
targets: [],
})
await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({
ignored: 0,
processed: 0,
targetCount: 0,
})
expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
})
it('dispatches one bounded page and returns its continuation cursor', async () => {
const events: string[] = []
mockFindTikTokWebhookTargetPage.mockImplementationOnce(async () => {
events.push('page-1')
return {
hasMore: true,
nextCursor: 'webhook-1',
targets: [firstTarget],
}
})
mockDispatchResolvedWebhookTarget.mockImplementationOnce(async () => {
events.push('dispatch-1')
return {
outcome: 'queued',
reason: 'queued',
response: new Response(null, { status: 200 }),
}
})
await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({
ignored: 0,
nextCursor: 'webhook-1',
processed: 1,
targetCount: 1,
})
expect(events).toEqual(['page-1', 'dispatch-1'])
expect(mockFindTikTokWebhookTargetPage).toHaveBeenNthCalledWith(
1,
'act.user',
'request-1',
undefined
)
})
it('enqueues the next page only after the current page succeeds', async () => {
mockFindTikTokWebhookTargetPage.mockResolvedValue({
hasMore: true,
nextCursor: 'webhook-1',
targets: [firstTarget],
})
mockDispatchResolvedWebhookTarget.mockResolvedValue({
outcome: 'queued',
reason: 'queued',
response: new Response(null, { status: 200 }),
})
await enqueueTikTokWebhookIngress(payload)
const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise<void> }
await options.runner()
expect(mockEnqueue).toHaveBeenNthCalledWith(
2,
'tiktok-webhook-ingress',
expect.objectContaining({ afterWebhookId: 'webhook-1' }),
expect.objectContaining({
jobId: 'tiktok-webhook-ingress:request-1:webhook-1',
})
)
})
it('throws after a target failure so the durable ingress job retries', async () => {
mockFindTikTokWebhookTargetPage.mockResolvedValueOnce({
hasMore: false,
nextCursor: 'webhook-2',
targets: [firstTarget, secondTarget],
})
mockDispatchResolvedWebhookTarget
.mockResolvedValueOnce({
outcome: 'failed',
reason: 'queue-failed',
response: new Response(null, { status: 500 }),
})
.mockResolvedValueOnce({
outcome: 'queued',
reason: 'queued',
response: new Response(null, { status: 200 }),
})
await expect(executeTikTokWebhookIngress(payload)).rejects.toThrow(
'Failed to dispatch 1 of 2 TikTok webhook targets'
)
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2)
})
it('fails closed if a full target page does not provide a forward cursor', async () => {
mockFindTikTokWebhookTargetPage.mockResolvedValue({
hasMore: true,
nextCursor: null,
targets: [],
})
await expect(executeTikTokWebhookIngress(payload)).rejects.toThrow(
'TikTok webhook target pagination did not advance'
)
})
})
@@ -1,148 +0,0 @@
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { NextRequest } from 'next/server'
import type { TikTokWebhookEnvelope } from '@/lib/api/contracts/webhooks'
import { getJobQueue } from '@/lib/core/async-jobs'
import { dispatchResolvedWebhookTarget } from '@/lib/webhooks/processor'
import { findTikTokWebhookTargetPage } from '@/background/tiktok-webhook-targets'
const logger = createLogger('TikTokWebhookIngressTask')
export const TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT = 50
export const TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS = 3
export interface TikTokWebhookIngressPayload {
afterWebhookId?: string
envelope: TikTokWebhookEnvelope
headers: {
'content-type': string
}
requestId: string
receivedAt: number
}
export interface TikTokWebhookIngressResult {
ignored: number
nextCursor?: string
processed: number
targetCount: number
}
/**
* Resolves and dispatches one fixed-size keyset page for a verified TikTok delivery. Each page is a
* separate durable job, so retries replay at most one bounded page and successful pages continue
* from their last webhook ID.
*/
export async function executeTikTokWebhookIngress(
payload: TikTokWebhookIngressPayload
): Promise<TikTokWebhookIngressResult> {
const request = new NextRequest('http://internal/api/webhooks/tiktok', {
method: 'POST',
headers: payload.headers,
body: JSON.stringify(payload.envelope),
})
const page = await findTikTokWebhookTargetPage(
payload.envelope.user_openid,
payload.requestId,
payload.afterWebhookId
)
const nextCursor = page.hasMore ? page.nextCursor : null
if (page.hasMore && (!nextCursor || nextCursor === payload.afterWebhookId)) {
throw new Error('TikTok webhook target pagination did not advance')
}
let ignored = 0
let processed = 0
let failed = 0
const targetCount = page.targets.length
for (const { webhook, workflow } of page.targets) {
const result = await dispatchResolvedWebhookTarget(
webhook,
workflow,
payload.envelope,
request,
{
requestId: payload.requestId,
path: webhook.path ?? undefined,
receivedAt: payload.receivedAt,
triggerTimestampMs: payload.envelope.create_time * 1000,
}
)
if (result.outcome === 'queued') {
processed += 1
} else if (result.outcome === 'ignored') {
ignored += 1
} else {
failed += 1
}
}
if (failed > 0) {
throw new Error(`Failed to dispatch ${failed} of ${targetCount} TikTok webhook targets`)
}
if (targetCount === 0) {
logger.info(`[${payload.requestId}] No TikTok webhook targets found in page`, {
event: payload.envelope.event,
userOpenIdPrefix: payload.envelope.user_openid.slice(0, 12),
})
return {
ignored: 0,
processed: 0,
targetCount: 0,
...(nextCursor ? { nextCursor } : {}),
}
}
logger.info(`[${payload.requestId}] TikTok webhook fanout page completed`, {
event: payload.envelope.event,
ignored,
nextCursor,
processed,
targetCount,
})
return { ignored, processed, targetCount, ...(nextCursor ? { nextCursor } : {}) }
}
async function runTikTokWebhookIngressJob(payload: TikTokWebhookIngressPayload): Promise<void> {
const result = await executeTikTokWebhookIngress(payload)
if (!result.nextCursor) return
await enqueueTikTokWebhookIngress({
...payload,
afterWebhookId: result.nextCursor,
})
}
/** Enqueues one bounded TikTok webhook fanout page with stable continuation identity. */
export async function enqueueTikTokWebhookIngress(
payload: TikTokWebhookIngressPayload
): Promise<string> {
const jobQueue = await getJobQueue()
return jobQueue.enqueue('tiktok-webhook-ingress', payload, {
jobId: `tiktok-webhook-ingress:${payload.requestId}:${payload.afterWebhookId ?? 'root'}`,
maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS,
concurrencyKey: 'tiktok-webhook-ingress',
concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,
runner: async () => runTikTokWebhookIngressJob(payload),
})
}
export const tiktokWebhookIngressTask = task({
id: 'tiktok-webhook-ingress',
machine: 'small-1x',
retry: {
maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS,
factor: 2,
minTimeoutInMs: 1000,
maxTimeoutInMs: 10_000,
},
queue: {
concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,
},
run: async (payload: TikTokWebhookIngressPayload) => runTikTokWebhookIngressJob(payload),
})
@@ -1,181 +0,0 @@
/**
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAsc, mockCredentialExpression, mockEq, mockGt, mockLike, tables } = vi.hoisted(() => ({
mockAsc: vi.fn((value: unknown) => ({ asc: value })),
mockCredentialExpression: vi.fn(() => 'webhook.credentialId'),
mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })),
mockGt: vi.fn((left: unknown, right: unknown) => ({ gt: [left, right] })),
mockLike: vi.fn((left: unknown, right: unknown) => ({ left, right })),
/** Table-qualified column names keep the eq/like assertions unambiguous. */
tables: {
account: {
id: 'account.id',
accountId: 'account.accountId',
providerId: 'account.providerId',
},
credential: {
id: 'credential.id',
accountId: 'credential.accountId',
providerId: 'credential.providerId',
type: 'credential.type',
workspaceId: 'credential.workspaceId',
},
webhook: {
deploymentVersionId: 'webhook.deploymentVersionId',
isActive: 'webhook.isActive',
id: 'webhook.id',
archivedAt: 'webhook.archivedAt',
provider: 'webhook.provider',
providerConfig: 'webhook.providerConfig',
workflowId: 'webhook.workflowId',
},
workflow: {
id: 'workflow.id',
workspaceId: 'workflow.workspaceId',
archivedAt: 'workflow.archivedAt',
},
workflowDeploymentVersion: {
id: 'workflowDeploymentVersion.id',
workflowId: 'workflowDeploymentVersion.workflowId',
isActive: 'workflowDeploymentVersion.isActive',
},
},
}))
vi.mock('@sim/db', () => ({
...dbChainMock,
...tables,
webhookCredentialIdExpression: mockCredentialExpression,
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => conditions),
asc: mockAsc,
eq: mockEq,
gt: mockGt,
isNull: vi.fn((value: unknown) => ({ isNull: value })),
like: mockLike,
or: vi.fn((...conditions: unknown[]) => conditions),
}))
import {
findTikTokWebhookTargetPage,
TIKTOK_WEBHOOK_TARGET_PAGE_SIZE,
} from '@/background/tiktok-webhook-targets'
const ACCOUNT_UUID = '11111111-2222-3333-4444-555555555555'
/** Queues one page of joined rows, clipped like the SQL LIMIT would. */
function queuePageRows(
rows: Array<{
accountId: string
webhookId: string
webhook: Record<string, unknown>
workflow: Record<string, unknown>
}>
) {
queueTableRows(tables.account, rows.slice(0, TIKTOK_WEBHOOK_TARGET_PAGE_SIZE))
}
describe('findTikTokWebhookTargetPage', () => {
afterAll(() => {
resetDbChainMock()
})
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('returns only rows whose stored account ID exactly matches user_openid', async () => {
queuePageRows([
{
accountId: `act.user-${ACCOUNT_UUID}`,
webhookId: 'webhook-1',
webhook: { id: 'webhook-1' },
workflow: { id: 'workflow-1' },
},
{
accountId: `act.user-other-${ACCOUNT_UUID}`,
webhookId: 'webhook-2',
webhook: { id: 'webhook-2' },
workflow: { id: 'workflow-2' },
},
])
const page = await findTikTokWebhookTargetPage('act.user', 'request-1')
expect(page).toEqual({
hasMore: false,
nextCursor: 'webhook-2',
targets: [
{
webhook: { id: 'webhook-1' },
workflow: { id: 'workflow-1' },
},
],
})
})
it('enforces provider and workspace bindings in the database query', async () => {
await findTikTokWebhookTargetPage('act.user', 'request-2')
expect(mockEq).toHaveBeenCalledWith('credential.providerId', 'tiktok')
expect(mockEq).toHaveBeenCalledWith('webhook.provider', 'tiktok')
expect(mockEq).toHaveBeenCalledWith('workflow.workspaceId', 'credential.workspaceId')
expect(mockCredentialExpression).toHaveBeenCalledWith('webhook.providerConfig')
expect(mockEq).toHaveBeenCalledWith('webhook.credentialId', 'credential.id')
})
it('uses a fixed-size webhook ID keyset in ascending order', async () => {
await findTikTokWebhookTargetPage('act.user', 'request-3', 'webhook-100')
expect(mockGt).toHaveBeenCalledWith('webhook.id', 'webhook-100')
expect(mockAsc).toHaveBeenCalledWith('webhook.id')
expect(dbChainMockFns.orderBy).toHaveBeenCalledWith({ asc: 'webhook.id' })
expect(dbChainMockFns.limit).toHaveBeenCalledWith(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE)
})
it('returns a continuation cursor when the fixed-size page is full', async () => {
queuePageRows(
Array.from({ length: TIKTOK_WEBHOOK_TARGET_PAGE_SIZE + 1 }, (_, index) => {
const webhookId = `webhook-${String(index).padStart(3, '0')}`
return {
accountId: `act.user-${ACCOUNT_UUID}`,
webhookId,
webhook: { id: webhookId },
workflow: { id: `workflow-${index}` },
}
})
)
const page = await findTikTokWebhookTargetPage('act.user', 'request-4')
expect(page.targets).toHaveLength(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE)
expect(page.hasMore).toBe(true)
expect(page.nextCursor).toBe('webhook-099')
})
it('escapes user_openid wildcard characters in the account lookup', async () => {
await findTikTokWebhookTargetPage('act_%', 'request-5')
expect(mockLike).toHaveBeenCalledWith(
'account.accountId',
'act\\_\\%-________-____-____-____-____________'
)
})
it('does not query for an empty user_openid', async () => {
expect(await findTikTokWebhookTargetPage('', 'request-6')).toEqual({
hasMore: false,
nextCursor: null,
targets: [],
})
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})
})
@@ -1,122 +0,0 @@
import {
account,
credential,
db,
webhook,
webhookCredentialIdExpression,
workflow,
workflowDeploymentVersion,
} from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, asc, eq, gt, isNull, like, or } from 'drizzle-orm'
import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate'
const logger = createLogger('TikTokWebhookTargets')
const ACCOUNT_ID_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const ACCOUNT_ID_UUID_LIKE_SUFFIX = '________-____-____-____-____________'
export const TIKTOK_WEBHOOK_TARGET_PAGE_SIZE = 100
export interface TikTokWebhookTarget {
webhook: typeof webhook.$inferSelect
workflow: typeof workflow.$inferSelect
}
export interface TikTokWebhookTargetPage {
hasMore: boolean
nextCursor: string | null
targets: TikTokWebhookTarget[]
}
function escapeLikePattern(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
}
function openIdFromAccountId(accountId: string): string {
return accountId.replace(ACCOUNT_ID_UUID_SUFFIX, '')
}
/**
* Resolves one deterministic page of active webhook targets for the TikTok background ingress.
* The workflow-workspace equality prevents cross-tenant event routing, while the webhook ID cursor
* keeps each query and retained result set bounded without offset drift.
*/
export async function findTikTokWebhookTargetPage(
userOpenId: string,
requestId: string,
afterWebhookId?: string
): Promise<TikTokWebhookTargetPage> {
if (!userOpenId) {
return { hasMore: false, nextCursor: null, targets: [] }
}
const rows = await db
.select({
accountId: account.accountId,
webhookId: webhook.id,
webhook,
workflow,
})
.from(account)
.innerJoin(
credential,
and(
eq(credential.accountId, account.id),
eq(credential.type, 'oauth'),
eq(credential.providerId, 'tiktok')
)
)
.innerJoin(
webhook,
and(
eq(webhookCredentialIdExpression(webhook.providerConfig), credential.id),
eq(webhook.provider, 'tiktok'),
deliverableWebhookPredicate(webhook)
)
)
.innerJoin(
workflow,
and(
eq(workflow.id, webhook.workflowId),
eq(workflow.workspaceId, credential.workspaceId),
isNull(workflow.archivedAt)
)
)
.leftJoin(
workflowDeploymentVersion,
and(
eq(workflowDeploymentVersion.workflowId, workflow.id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.where(
and(
eq(account.providerId, 'tiktok'),
like(account.accountId, `${escapeLikePattern(userOpenId)}-${ACCOUNT_ID_UUID_LIKE_SUFFIX}`),
or(
eq(webhook.deploymentVersionId, workflowDeploymentVersion.id),
and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId))
),
afterWebhookId ? gt(webhook.id, afterWebhookId) : undefined
)
)
.orderBy(asc(webhook.id))
.limit(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE)
const targets = rows
.filter((row) => openIdFromAccountId(row.accountId) === userOpenId)
.map(({ webhook: webhookRecord, workflow: workflowRecord }) => ({
webhook: webhookRecord,
workflow: workflowRecord,
}))
const nextCursor = rows.at(-1)?.webhookId ?? null
const hasMore = rows.length === TIKTOK_WEBHOOK_TARGET_PAGE_SIZE
logger.info(`[${requestId}] Resolved TikTok webhook target page`, {
hasMore,
userOpenIdPrefix: userOpenId.slice(0, 12),
targetCount: targets.length,
})
return { hasMore, nextCursor, targets }
}
-6
View File
@@ -43,10 +43,4 @@ describe('TikTokBlock', () => {
expect(finalInputs.file).toEqual(file)
expect(finalInputs.videoIds).toBeUndefined()
})
it('declares file-like outputs with canonical block output types', () => {
expect(TikTokBlock.outputs.avatarFile.type).toBe('file')
expect(TikTokBlock.outputs.videos.type).toBe('array')
expect(TikTokBlock.outputs.publiclyAvailablePostId.type).toBe('array')
})
})
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest'
import { tiktokUploadVideoDraftBodySchema } from '@/lib/api/contracts/tiktok-tools'
const file = {
key: 'workspace/test/video.mp4',
name: 'video.mp4',
size: 1024,
type: 'video/mp4',
}
describe('tiktokUploadVideoDraftBodySchema', () => {
it('accepts a draft upload body', () => {
const parsed = tiktokUploadVideoDraftBodySchema.safeParse({
accessToken: 'token',
file,
})
expect(parsed.success).toBe(true)
if (parsed.success) {
expect(parsed.data).toEqual({ accessToken: 'token', file })
}
})
it('rejects a body without an access token', () => {
expect(
tiktokUploadVideoDraftBodySchema.safeParse({
file,
}).success
).toBe(false)
})
it('rejects a body without a file', () => {
expect(
tiktokUploadVideoDraftBodySchema.safeParse({
accessToken: 'token',
}).success
).toBe(false)
})
})
@@ -176,7 +176,6 @@ const JOB_TYPE_TO_TASK_ID: Record<JobType, string> = {
'workflow-execution': 'workflow-execution',
'schedule-execution': 'schedule-execution',
'webhook-execution': 'webhook-execution',
'tiktok-webhook-ingress': 'tiktok-webhook-ingress',
'resume-execution': 'resume-execution',
'workflow-group-cell': 'workflow-group-cell',
'cleanup-logs': 'cleanup-logs',
-1
View File
@@ -33,7 +33,6 @@ export type JobType =
| 'workflow-execution'
| 'schedule-execution'
| 'webhook-execution'
| 'tiktok-webhook-ingress'
| 'resume-execution'
| 'workflow-group-cell'
| 'cleanup-logs'
@@ -0,0 +1,41 @@
/**
* @vitest-environment node
*/
import {
dbChainMock,
dbChainMockFns,
drizzleOrmMock,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
vi.mock('@sim/db/schema', () => schemaMock)
vi.mock('drizzle-orm', () => drizzleOrmMock)
import { clearCredentialRefs } from '@/lib/credentials/deletion'
describe('credential-bound webhook deactivation', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
afterAll(() => {
resetDbChainMock()
})
it('deactivates TikTok and Slack webhook registrations when a credential is removed', async () => {
await clearCredentialRefs('credential-1', 'workspace-1')
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.webhook)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ isActive: false, updatedAt: expect.any(Date) })
)
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'tiktok')
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack_app')
expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack')
})
})
+10 -6
View File
@@ -90,18 +90,18 @@ export async function clearCredentialRefs(
clearInPausedExecutions(credentialId, workspaceId, needle),
clearInWorkflowCheckpoints(credentialId, workspaceId, needle),
clearInKnowledgeConnectors(credentialId),
deactivateSlackAppWebhooks(credentialId),
deactivateCredentialBoundWebhooks(credentialId),
])
}
/**
* Deactivates Slack trigger webhooks bound to this credential so inbound
* events stop routing once the account is disconnected: native (`slack_app`)
* rows reference it via `providerConfig.credentialId`, custom-bot (`slack`)
* rows via `routingKey` = the bot credential id. Neither is a foreign key, so
* Deactivates app-level trigger webhooks bound to this credential so inbound
* events stop routing once the account is disconnected. Native Slack and
* TikTok rows reference it via `providerConfig.credentialId`; custom-bot Slack
* rows use `routingKey` = the bot credential id. Neither is a foreign key, so
* neither is covered by CASCADE.
*/
async function deactivateSlackAppWebhooks(credentialId: string): Promise<void> {
async function deactivateCredentialBoundWebhooks(credentialId: string): Promise<void> {
await db
.update(schema.webhook)
.set({ isActive: false, updatedAt: new Date() })
@@ -113,6 +113,10 @@ async function deactivateSlackAppWebhooks(credentialId: string): Promise<void> {
eq(schema.webhook.provider, 'slack_app'),
sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}`
),
and(
eq(schema.webhook.provider, 'tiktok'),
sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}`
),
and(eq(schema.webhook.provider, 'slack'), eq(schema.webhook.routingKey, credentialId))
)
)
+69
View File
@@ -97,6 +97,15 @@ const slackTrigger = trigger([
},
])
const tiktokTrigger = trigger([
{
id: 'triggerCredentials',
mode: 'trigger',
serviceId: 'tiktok',
required: true,
},
])
function makeBlock(
type: string,
subBlockValues: Record<string, unknown>,
@@ -364,3 +373,63 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => {
expect(mockFetchSlackTeamId).not.toHaveBeenCalled()
})
})
describe('resolveWebhookConfigForBlock — TikTok routing', () => {
const tiktokTriggerDef = {
provider: 'tiktok',
name: 'TikTok',
subBlocks: tiktokTrigger.subBlocks,
}
function resolveTikTok(
credentialReference = 'credential-1',
workflow: Record<string, unknown> = { workspaceId: 'ws-1' }
) {
;(getBlock as unknown as Mock).mockReturnValue({ category: 'triggers' })
;(getTrigger as unknown as Mock).mockReturnValue(tiktokTriggerDef)
return resolveWebhookConfigForBlock({
block: makeBlock('tiktok', { triggerCredentials: credentialReference }),
workflow,
userId: 'deployer-1',
requestId: 'req-1',
})
}
it('routes a canonical workspace credential by its TikTok open_id', async () => {
queueTableRows(credential, [{ id: 'credential-1' }])
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'account-1' })
queueTableRows(account, [
{ accountId: 'open-id-with-hyphens-12345678-1234-1234-1234-123456789abc' },
])
const result = await resolveTikTok()
expect(result?.success).toBe(true)
if (!result?.success) throw new Error('expected success')
expect(result.config.provider).toBe('tiktok')
expect(result.config.routingKey).toBe('open-id-with-hyphens')
expect(result.config.triggerPath).toBeNull()
expect(result.config.providerConfig.credentialId).toBe('credential-1')
})
it('rejects a TikTok credential not available in the workflow workspace', async () => {
const result = await resolveTikTok('foreign-credential')
expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error.message).toContain('not available in this workspace')
expect(mockResolveOAuthAccountId).not.toHaveBeenCalled()
})
it('rejects a malformed TikTok account identity', async () => {
queueTableRows(credential, [{ id: 'credential-1' }])
mockResolveOAuthAccountId.mockResolvedValue({ accountId: 'account-1' })
queueTableRows(account, [{ accountId: 'missing-generated-uuid' }])
const result = await resolveTikTok()
expect(result?.success).toBe(false)
if (result?.success) throw new Error('expected failure')
expect(result?.error.message).toContain('Reconnect')
})
})
+33 -3
View File
@@ -40,6 +40,7 @@ import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared'
import { resolveBlockTriggerId } from '@/triggers/webhook-url'
const logger = createLogger('DeployWebhookSync')
const TIKTOK_ACCOUNT_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
interface TriggerSaveError {
message: string
@@ -325,9 +326,8 @@ export async function resolveTriggerCredentialId(
}
/**
* Resolves a trigger block to its persisted webhook config, including the
* Slack-specific routing branch. Exported for unit testing that branch; not part
* of the public deploy API.
* Resolves a trigger block to its persisted webhook config, including app-level
* provider routing. Exported for focused unit testing; not part of the public deploy API.
*/
export async function resolveWebhookConfigForBlock(input: {
block: BlockState
@@ -528,6 +528,36 @@ export async function resolveWebhookConfigForBlock(input: {
// (`slack_app`) rows on providerConfig.credentialId.
providerConfig.credentialId = resolvedCredentialId
}
} else if (triggerDef.provider === 'tiktok') {
if (!credentialId) {
return {
success: false,
error: { message: 'Select a TikTok account for the trigger.', status: 400 },
}
}
const resolvedAccount = await resolveOAuthAccountId(credentialId)
const [tiktokAccount] = resolvedAccount?.accountId
? await db
.select({ accountId: account.accountId })
.from(account)
.where(and(eq(account.id, resolvedAccount.accountId), eq(account.providerId, 'tiktok')))
.limit(1)
: []
const openId = tiktokAccount?.accountId.replace(TIKTOK_ACCOUNT_UUID_SUFFIX, '')
if (!openId || openId === tiktokAccount?.accountId) {
return {
success: false,
error: {
message: 'Could not verify the connected TikTok account. Reconnect it and try again.',
status: 400,
},
}
}
effectivePath = null
routingKey = openId
}
return {
@@ -1,12 +0,0 @@
import { describe, expect, it } from 'vitest'
import { tiktokGetPostStatusTool } from '@/tools/tiktok/get_post_status'
describe('TikTok Get Post Status OAuth scopes', () => {
it('requires the draft-upload scope used by the supported posting flow', () => {
expect(tiktokGetPostStatusTool.oauth).toEqual({
required: true,
provider: 'tiktok',
requiredScopes: ['video.upload'],
})
})
})
@@ -0,0 +1,9 @@
-- TikTok now uses the shared routing-key lookup. No deployed TikTok webhooks
-- exist to backfill, so only remove the obsolete provider-specific index.
COMMIT;
--> statement-breakpoint
SET lock_timeout = 0;
--> statement-breakpoint
DROP INDEX CONCURRENTLY IF EXISTS "webhook_tiktok_credential_id_idx";
--> statement-breakpoint
SET lock_timeout = '5s';
File diff suppressed because it is too large Load Diff
@@ -1996,6 +1996,13 @@
"when": 1786217700133,
"tag": "0285_workspace_inbox_provider_id_idx",
"breakpoints": true
},
{
"idx": 286,
"version": "7",
"when": 1786226773546,
"tag": "0286_tiktok_routing_key",
"breakpoints": true
}
]
}
+5 -15
View File
@@ -838,11 +838,6 @@ export const jobExecutionLogs = pgTable(
})
)
/** Extracts the canonical credential ID persisted in webhook provider configuration. */
export function webhookCredentialIdExpression(column: AnyPgColumn): SQL<string> {
return sql<string>`((${column})::jsonb ->> 'credentialId')`
}
export const webhook = pgTable(
'webhook',
{
@@ -861,15 +856,15 @@ export const webhook = pgTable(
blockId: text('block_id'),
/**
* URL-addressable webhook path. NULL for shared-app providers (e.g. the
* native Slack OAuth trigger) whose events arrive on a single shared
* native Slack and TikTok triggers) whose events arrive on a single shared
* endpoint and route by `routingKey` instead of a per-workflow path.
*/
path: text('path'),
/**
* Tenant routing key for shared-app providers. For `provider='slack_app'`
* this is the Slack `team_id`, derived server-side from the connected
* credential at deploy time — never user input. Inbound events match on
* this after HMAC verification.
* Tenant routing key for shared-app providers, such as Slack `team_id` or
* TikTok `open_id`, derived server-side from the connected credential at
* deploy time — never user input. Inbound events match on this after HMAC
* verification.
*/
routingKey: text('routing_key'),
provider: text('provider'), // e.g., "whatsapp", "github", etc.
@@ -901,11 +896,6 @@ export const webhook = pgTable(
providerActiveWorkflowDeploymentIdx: index(
'idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468'
).on(table.provider, table.isActive, table.workflowId, table.deploymentVersionId),
tiktokCredentialIdIdx: index('webhook_tiktok_credential_id_idx')
.on(webhookCredentialIdExpression(table.providerConfig))
.where(
sql`${table.provider} = 'tiktok' AND ${table.isActive} = true AND ${table.archivedAt} IS NULL`
),
workflowBlockUpdatedDescIdx: index('idx_webhook_on_workflow_id_block_id_updated_at_desc').on(
table.workflowId,
table.blockId,