Skip to content

Commit 165fc4b

Browse files
committed
cleanup code
1 parent d3b624a commit 165fc4b

43 files changed

Lines changed: 18675 additions & 849 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/tools/tiktok/publish-video/route.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
1616
import { assertToolFileAccess } from '@/app/api/files/authorization'
1717
import type { UserFile } from '@/executor/types'
18+
import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas'
19+
import { readTikTokApiResponse } from '@/tools/tiktok/utils'
1820

1921
export const dynamic = 'force-dynamic'
2022

@@ -159,18 +161,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
159161
body: JSON.stringify(initBody),
160162
})
161163

162-
const initData = await initResponse.json()
164+
const { data: initData, error: initError } = await readTikTokApiResponse(
165+
initResponse,
166+
tiktokPublishInitApiDataSchema
167+
)
163168

164-
if (initData.error?.code && initData.error.code !== 'ok') {
165-
logger.error(`[${requestId}] TikTok init failed`, { error: initData.error })
169+
if (initError) {
170+
logger.error(`[${requestId}] TikTok init failed`, { error: initError })
166171
return NextResponse.json(
167-
{ success: false, error: initData.error.message || 'Failed to initialize TikTok upload' },
172+
{ success: false, error: initError.message || 'Failed to initialize TikTok upload' },
168173
{ status: initResponse.status >= 400 ? initResponse.status : 502 }
169174
)
170175
}
171176

172-
const publishId: string | undefined = initData.data?.publish_id
173-
const uploadUrl: string | undefined = initData.data?.upload_url
177+
const publishId = initData?.publish_id
178+
const uploadUrl = initData?.upload_url
174179

175180
if (!publishId || !uploadUrl) {
176181
return NextResponse.json(
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import crypto from 'node:crypto'
6+
import { NextRequest } from 'next/server'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockEnqueue, mockExecuteTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({
10+
mockEnqueue: vi.fn(),
11+
mockExecuteTikTokWebhookIngress: vi.fn(),
12+
mockRelease: vi.fn(),
13+
}))
14+
15+
vi.mock('@/background/tiktok-webhook-ingress', () => ({
16+
executeTikTokWebhookIngress: mockExecuteTikTokWebhookIngress,
17+
TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT: 50,
18+
TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS: 3,
19+
}))
20+
21+
vi.mock('@/lib/core/admission/gate', () => ({
22+
admissionRejectedResponse: vi.fn(() => new Response(null, { status: 503 })),
23+
tryAdmit: vi.fn(() => ({ release: mockRelease })),
24+
}))
25+
26+
vi.mock('@/lib/core/async-jobs', () => ({
27+
getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })),
28+
}))
29+
30+
vi.mock('@/lib/core/config/env', () => ({
31+
env: {
32+
TIKTOK_CLIENT_ID: 'client-key',
33+
TIKTOK_CLIENT_SECRET: 'client-secret',
34+
},
35+
}))
36+
37+
vi.mock('@/lib/core/utils/request', () => ({
38+
generateRequestId: vi.fn(() => 'request-1'),
39+
}))
40+
41+
vi.mock('@/lib/core/utils/with-route-handler', () => ({
42+
withRouteHandler:
43+
(handler: (request: NextRequest) => Promise<Response>) => (request: NextRequest) =>
44+
handler(request),
45+
}))
46+
47+
import { POST } from '@/app/api/webhooks/tiktok/route'
48+
49+
function signedRequest(overrides?: { clientKey?: string }): NextRequest {
50+
const body = JSON.stringify({
51+
client_key: overrides?.clientKey ?? 'client-key',
52+
event: 'post.publish.complete',
53+
create_time: 1_725_000_000,
54+
user_openid: 'act.user',
55+
content: '{"publish_id":"publish-1"}',
56+
})
57+
const timestamp = String(Math.floor(Date.now() / 1000))
58+
const signature = crypto
59+
.createHmac('sha256', 'client-secret')
60+
.update(`${timestamp}.${body}`, 'utf8')
61+
.digest('hex')
62+
63+
return new NextRequest('http://localhost/api/webhooks/tiktok', {
64+
method: 'POST',
65+
headers: {
66+
'content-type': 'application/json',
67+
'TikTok-Signature': `t=${timestamp},s=${signature}`,
68+
},
69+
body,
70+
})
71+
}
72+
73+
describe('TikTok webhook ingress route', () => {
74+
beforeEach(() => {
75+
vi.clearAllMocks()
76+
mockEnqueue.mockResolvedValue('ingress-job-1')
77+
})
78+
79+
it('returns 200 only after the verified delivery is accepted by the job queue', async () => {
80+
const response = await POST(signedRequest())
81+
82+
expect(response.status).toBe(200)
83+
await expect(response.json()).resolves.toEqual({ ok: true })
84+
expect(mockEnqueue).toHaveBeenCalledWith(
85+
'tiktok-webhook-ingress',
86+
expect.objectContaining({
87+
envelope: expect.objectContaining({
88+
client_key: 'client-key',
89+
user_openid: 'act.user',
90+
}),
91+
requestId: 'request-1',
92+
}),
93+
expect.objectContaining({
94+
maxAttempts: 3,
95+
runner: expect.any(Function),
96+
})
97+
)
98+
expect(mockRelease).toHaveBeenCalledOnce()
99+
})
100+
101+
it('returns 503 when durable acceptance fails so TikTok retries', async () => {
102+
mockEnqueue.mockRejectedValue(new Error('queue unavailable'))
103+
104+
const response = await POST(signedRequest())
105+
106+
expect(response.status).toBe(503)
107+
expect(mockRelease).toHaveBeenCalledOnce()
108+
})
109+
110+
it('rejects a signed delivery for a different TikTok app', async () => {
111+
const response = await POST(signedRequest({ clientKey: 'other-client-key' }))
112+
113+
expect(response.status).toBe(401)
114+
expect(mockEnqueue).not.toHaveBeenCalled()
115+
})
116+
})

apps/sim/app/api/webhooks/tiktok/route.ts

Lines changed: 37 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
4-
import {
5-
tiktokWebhookEnvelopeSchema,
6-
tiktokWebhookHeadersSchema,
7-
} from '@/lib/api/contracts/webhooks'
8-
import {
9-
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
10-
isWorkspaceApiExecutionEntitled,
11-
} from '@/lib/billing/core/api-access'
4+
import { tiktokWebhookEnvelopeSchema } from '@/lib/api/contracts/webhooks'
125
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
6+
import { getJobQueue } from '@/lib/core/async-jobs'
7+
import { env } from '@/lib/core/config/env'
138
import { generateRequestId } from '@/lib/core/utils/request'
149
import {
1510
assertContentLengthWithinLimit,
@@ -18,30 +13,13 @@ import {
1813
} from '@/lib/core/utils/stream-limits'
1914
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2015
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
21-
import {
22-
checkWebhookPreprocessing,
23-
handlePreDeploymentVerification,
24-
queueWebhookExecution,
25-
shouldSkipWebhookEvent,
26-
} from '@/lib/webhooks/processor'
2716
import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok'
28-
import { findTikTokWebhooksForOpenId } from '@/lib/webhooks/tiktok-fanout'
29-
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
30-
import { isTikTokEventMatch } from '@/triggers/tiktok/utils'
31-
32-
/**
33-
* queueWebhookExecution returns 200 for both real queues and event mismatches.
34-
* Only count responses that indicate work was actually enqueued.
35-
*/
36-
async function didQueueWebhookExecution(response: NextResponse): Promise<boolean> {
37-
if (!response.ok) return false
38-
try {
39-
const payload = (await response.clone().json()) as Record<string, unknown>
40-
return payload.message === 'Webhook processed'
41-
} catch {
42-
return false
43-
}
44-
}
17+
import {
18+
executeTikTokWebhookIngress,
19+
TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,
20+
TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS,
21+
type TikTokWebhookIngressPayload,
22+
} from '@/background/tiktok-webhook-ingress'
4523

4624
const logger = createLogger('TikTokWebhookIngress')
4725

@@ -63,7 +41,7 @@ async function readTikTokBody(req: Request): Promise<string> {
6341
/**
6442
* App-level TikTok webhook Callback URL.
6543
* Portal: `{APP_URL}/api/webhooks/tiktok` (e.g. https://www.sim.ai/api/webhooks/tiktok).
66-
* Verifies TikTok-Signature once, then fans out by user_openid → credential → workflows.
44+
* Verifies TikTok-Signature and durably accepts the delivery before background target fanout.
6745
*/
6846
export const POST = withRouteHandler(async (request: NextRequest) => {
6947
const ticket = tryAdmit()
@@ -89,16 +67,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8967
throw bodyError
9068
}
9169

92-
const headersResult = tiktokWebhookHeadersSchema.safeParse({
93-
'tiktok-signature': request.headers.get('TikTok-Signature'),
94-
})
95-
if (!headersResult.success) {
96-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
97-
}
98-
9970
const authError = verifyTikTokSignature(
10071
rawBody,
101-
headersResult.data['tiktok-signature'],
72+
request.headers.get('TikTok-Signature'),
10273
requestId
10374
)
10475
if (authError) {
@@ -123,109 +94,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
12394
}
12495

12596
const envelope = envelopeResult.data
126-
const matches = await findTikTokWebhooksForOpenId(envelope.user_openid, requestId)
127-
128-
if (matches.length === 0) {
129-
logger.info(`[${requestId}] No matching TikTok webhooks; acknowledging`, {
130-
event: envelope.event,
131-
userOpenIdPrefix: envelope.user_openid.slice(0, 12),
132-
})
133-
return NextResponse.json({ ok: true })
97+
if (!env.TIKTOK_CLIENT_ID || envelope.client_key !== env.TIKTOK_CLIENT_ID) {
98+
logger.warn(`[${requestId}] TikTok webhook client_key does not match configured app`)
99+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
134100
}
135101

136-
let processed = 0
137-
for (const { webhook: foundWebhook, workflow: foundWorkflow } of matches) {
138-
// Schema allows null provider; fan-out already filtered to provider = 'tiktok'.
139-
const webhookRecord = {
140-
...foundWebhook,
141-
provider: foundWebhook.provider ?? 'tiktok',
142-
providerConfig:
143-
(foundWebhook.providerConfig as Record<string, unknown> | null) ?? undefined,
144-
}
145-
146-
if (
147-
foundWorkflow.workspaceId &&
148-
!(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId))
149-
) {
150-
logger.warn(`[${requestId}] Workspace not entitled for TikTok webhook`, {
151-
webhookId: webhookRecord.id,
152-
workspaceId: foundWorkflow.workspaceId,
153-
})
154-
continue
155-
}
156-
157-
const preprocessResult = await checkWebhookPreprocessing(
158-
foundWorkflow,
159-
webhookRecord,
160-
requestId
161-
)
162-
if (preprocessResult.error) {
163-
logger.warn(`[${requestId}] Preprocessing failed for TikTok webhook`, {
164-
webhookId: webhookRecord.id,
165-
})
166-
continue
167-
}
168-
169-
if (webhookRecord.blockId) {
170-
const blockExists = await blockExistsInDeployment(foundWorkflow.id, webhookRecord.blockId)
171-
if (!blockExists) {
172-
const preDeploymentResponse = handlePreDeploymentVerification(webhookRecord, requestId)
173-
if (preDeploymentResponse) {
174-
continue
175-
}
176-
logger.info(
177-
`[${requestId}] Trigger block ${webhookRecord.blockId} not found in deployment for workflow ${foundWorkflow.id}`
178-
)
179-
continue
180-
}
181-
}
182-
183-
if (shouldSkipWebhookEvent(webhookRecord, envelope, requestId)) {
184-
continue
185-
}
186-
187-
const triggerId = webhookRecord.providerConfig?.triggerId
188-
if (
189-
typeof triggerId === 'string' &&
190-
triggerId.length > 0 &&
191-
!isTikTokEventMatch(triggerId, envelope.event)
192-
) {
193-
continue
194-
}
195-
196-
const queueResponse = await queueWebhookExecution(
197-
webhookRecord,
198-
foundWorkflow,
199-
envelope,
200-
request,
201-
{
202-
requestId,
203-
path: webhookRecord.path,
204-
actorUserId: preprocessResult.actorUserId,
205-
executionId: preprocessResult.executionId,
206-
correlation: preprocessResult.correlation,
207-
receivedAt,
208-
}
209-
)
210-
if (await didQueueWebhookExecution(queueResponse)) {
211-
processed += 1
212-
}
102+
const payload: TikTokWebhookIngressPayload = {
103+
envelope,
104+
headers: {
105+
'content-type': request.headers.get('content-type') ?? 'application/json',
106+
},
107+
requestId,
108+
receivedAt,
213109
}
110+
const jobQueue = await getJobQueue()
111+
const jobId = await jobQueue.enqueue('tiktok-webhook-ingress', payload, {
112+
maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS,
113+
concurrencyKey: 'tiktok-webhook-ingress',
114+
concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,
115+
runner: async () => {
116+
await executeTikTokWebhookIngress(payload)
117+
},
118+
})
214119

215-
if (processed === 0 && matches.length > 0) {
216-
logger.info(`[${requestId}] TikTok webhooks matched but none processed`, {
217-
matchCount: matches.length,
218-
hint: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
219-
})
220-
}
120+
logger.info(`[${requestId}] Accepted TikTok webhook delivery`, {
121+
event: envelope.event,
122+
jobId,
123+
userOpenIdPrefix: envelope.user_openid.slice(0, 12),
124+
})
221125

222-
return NextResponse.json({ ok: true, webhooksProcessed: processed })
126+
return NextResponse.json({ ok: true })
223127
} catch (error) {
224128
logger.error(`[${requestId}] TikTok webhook ingress error`, {
225129
error: getErrorMessage(error, 'Unknown error'),
226130
})
227-
// Still 200 after accept path failures that aren't auth — TikTok retries on non-200.
228-
return NextResponse.json({ ok: true })
131+
return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 })
229132
} finally {
230133
ticket.release()
231134
}

0 commit comments

Comments
 (0)