From 7acda088821e982e38d554c4d0ef8dc46e003226 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:14:51 +0100 Subject: [PATCH 01/43] feat(tokmetric): add TikTok publishing contracts and chunk planning --- src/lib/tokmetric/publishing/types.ts | 82 +++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/lib/tokmetric/publishing/types.ts diff --git a/src/lib/tokmetric/publishing/types.ts b/src/lib/tokmetric/publishing/types.ts new file mode 100644 index 00000000..51d20fe8 --- /dev/null +++ b/src/lib/tokmetric/publishing/types.ts @@ -0,0 +1,82 @@ +export const TIKTOK_VIDEO_MIME_TYPES = [ + "video/mp4", + "video/quicktime", + "video/webm", +] as const; + +export type TikTokVideoMimeType = (typeof TIKTOK_VIDEO_MIME_TYPES)[number]; + +export const TIKTOK_PRIVACY_LEVELS = [ + "PUBLIC_TO_EVERYONE", + "MUTUAL_FOLLOW_FRIENDS", + "FOLLOWER_OF_CREATOR", + "SELF_ONLY", +] as const; + +export type TikTokPrivacyLevel = (typeof TIKTOK_PRIVACY_LEVELS)[number]; +export type TikTokVideoSource = "FILE_UPLOAD" | "PULL_FROM_URL"; + +export type TikTokCreatorInfo = { + creatorAvatarUrl: string | null; + creatorUsername: string; + creatorNickname: string; + privacyLevelOptions: TikTokPrivacyLevel[]; + commentDisabled: boolean; + duetDisabled: boolean; + stitchDisabled: boolean; + maxVideoPostDurationSec: number; +}; + +export type TikTokChunkPlan = { + videoSize: number; + chunkSize: number; + totalChunkCount: number; +}; + +export type TikTokPublishStatus = { + status: string; + failReason: string | null; + publiclyAvailablePostIds: string[]; + uploadedBytes: number | null; + downloadedBytes: number | null; + isFinal: boolean; + succeeded: boolean; +}; + +const FIVE_MIB = 5 * 1024 * 1024; +const SIXTY_FOUR_MIB = 64 * 1024 * 1024; +const FOUR_GIB = 4 * 1024 * 1024 * 1024; + +export function calculateTikTokChunkPlan(videoSize: number): TikTokChunkPlan { + if (!Number.isSafeInteger(videoSize) || videoSize <= 0) { + throw new Error("Video size must be a positive safe integer."); + } + if (videoSize > FOUR_GIB) { + throw new Error("TikTok video uploads cannot exceed 4 GB."); + } + + if (videoSize < FIVE_MIB) { + return { videoSize, chunkSize: videoSize, totalChunkCount: 1 }; + } + + const chunkSize = Math.min(videoSize, SIXTY_FOUR_MIB); + const totalChunkCount = Math.max(1, Math.floor(videoSize / chunkSize)); + if (totalChunkCount > 1000) { + throw new Error("TikTok upload would exceed the maximum chunk count."); + } + + return { videoSize, chunkSize, totalChunkCount }; +} + +export function chunkByteRange(plan: TikTokChunkPlan, index: number) { + if (!Number.isInteger(index) || index < 0 || index >= plan.totalChunkCount) { + throw new Error("Chunk index is outside the upload plan."); + } + + const start = index * plan.chunkSize; + const end = index === plan.totalChunkCount - 1 + ? plan.videoSize - 1 + : Math.min(plan.videoSize - 1, start + plan.chunkSize - 1); + + return { start, end, length: end - start + 1 }; +} From d662c19c7683d0857828bd80504120a1bebe9d4d Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:15:13 +0100 Subject: [PATCH 02/43] feat(tokmetric): add official TikTok Content Posting adapter --- src/lib/tokmetric/publishing/tiktok.ts | 178 +++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/lib/tokmetric/publishing/tiktok.ts diff --git a/src/lib/tokmetric/publishing/tiktok.ts b/src/lib/tokmetric/publishing/tiktok.ts new file mode 100644 index 00000000..d01a1665 --- /dev/null +++ b/src/lib/tokmetric/publishing/tiktok.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; +import { TokMetricError } from "@/lib/tokmetric/security"; +import { + TIKTOK_PRIVACY_LEVELS, + type TikTokChunkPlan, + type TikTokCreatorInfo, + type TikTokPrivacyLevel, + type TikTokPublishStatus, + type TikTokVideoSource, +} from "./types"; + +const TIKTOK_API_ORIGIN = "https://open.tiktokapis.com"; + +const errorSchema = z.object({ + code: z.string(), + message: z.string().optional().default(""), + log_id: z.string().optional(), + logid: z.string().optional(), +}); + +const creatorInfoSchema = z.object({ + data: z.object({ + creator_avatar_url: z.string().nullable().optional(), + creator_username: z.string(), + creator_nickname: z.string(), + privacy_level_options: z.array(z.enum(TIKTOK_PRIVACY_LEVELS)), + comment_disabled: z.boolean(), + duet_disabled: z.boolean(), + stitch_disabled: z.boolean(), + max_video_post_duration_sec: z.number().int().positive(), + }), + error: errorSchema, +}); + +const initSchema = z.object({ + data: z.object({ + publish_id: z.string().min(1).max(64), + upload_url: z.string().url().optional(), + }), + error: errorSchema, +}); + +const statusSchema = z.object({ + data: z.object({ + status: z.string(), + fail_reason: z.string().nullable().optional(), + publicaly_available_post_id: z.array(z.union([z.string(), z.number()])).optional(), + publicly_available_post_id: z.array(z.union([z.string(), z.number()])).optional(), + uploaded_bytes: z.number().int().nonnegative().optional(), + downloaded_bytes: z.number().int().nonnegative().optional(), + }), + error: errorSchema, +}); + +async function callTikTok( + accessToken: string, + path: string, + schema: T, + body?: unknown, +): Promise> { + const response = await fetch(`${TIKTOK_API_ORIGIN}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json; charset=UTF-8", + }, + body: body === undefined ? undefined : JSON.stringify(body), + cache: "no-store", + signal: AbortSignal.timeout(30_000), + }).catch(() => { + throw new TokMetricError(502, "TIKTOK_NETWORK_ERROR", "TikTok could not be reached. Try again shortly."); + }); + + const payload = await response.json().catch(() => null); + const parsed = schema.safeParse(payload); + if (!parsed.success) { + throw new TokMetricError(502, "TIKTOK_INVALID_RESPONSE", "TikTok returned an unexpected response."); + } + + if (!response.ok || parsed.data.error.code !== "ok") { + const code = parsed.data.error.code || "unknown_error"; + const status = response.status === 401 ? 401 : response.status === 429 ? 429 : 502; + throw new TokMetricError(status, `TIKTOK_${code.toUpperCase()}`, parsed.data.error.message || "TikTok rejected the request."); + } + + return parsed.data; +} + +export async function queryTikTokCreatorInfo(accessToken: string): Promise { + const response = await callTikTok(accessToken, "/v2/post/publish/creator_info/query/", creatorInfoSchema); + return { + creatorAvatarUrl: response.data.creator_avatar_url ?? null, + creatorUsername: response.data.creator_username, + creatorNickname: response.data.creator_nickname, + privacyLevelOptions: response.data.privacy_level_options, + commentDisabled: response.data.comment_disabled, + duetDisabled: response.data.duet_disabled, + stitchDisabled: response.data.stitch_disabled, + maxVideoPostDurationSec: response.data.max_video_post_duration_sec, + }; +} + +export type TikTokDirectPostInput = { + title: string; + privacyLevel: TikTokPrivacyLevel; + disableComment: boolean; + disableDuet: boolean; + disableStitch: boolean; + videoCoverTimestampMs?: number; + brandContentToggle: boolean; + brandOrganicToggle: boolean; + isAigc: boolean; + source: TikTokVideoSource; + chunkPlan?: TikTokChunkPlan; + videoUrl?: string; +}; + +export async function initializeTikTokDirectPost(accessToken: string, input: TikTokDirectPostInput) { + const sourceInfo = input.source === "FILE_UPLOAD" + ? { + source: "FILE_UPLOAD" as const, + video_size: input.chunkPlan?.videoSize, + chunk_size: input.chunkPlan?.chunkSize, + total_chunk_count: input.chunkPlan?.totalChunkCount, + } + : { + source: "PULL_FROM_URL" as const, + video_url: input.videoUrl, + }; + + const response = await callTikTok(accessToken, "/v2/post/publish/video/init/", initSchema, { + post_info: { + title: input.title, + privacy_level: input.privacyLevel, + disable_comment: input.disableComment, + disable_duet: input.disableDuet, + disable_stitch: input.disableStitch, + ...(input.videoCoverTimestampMs === undefined ? {} : { video_cover_timestamp_ms: input.videoCoverTimestampMs }), + brand_content_toggle: input.brandContentToggle, + brand_organic_toggle: input.brandOrganicToggle, + is_aigc: input.isAigc, + }, + source_info: sourceInfo, + }); + + if (input.source === "FILE_UPLOAD" && !response.data.upload_url) { + throw new TokMetricError(502, "TIKTOK_UPLOAD_URL_MISSING", "TikTok did not return a video upload URL."); + } + + return { + publishId: response.data.publish_id, + uploadUrl: response.data.upload_url ?? null, + }; +} + +export async function fetchTikTokPublishStatus(accessToken: string, publishId: string): Promise { + const response = await callTikTok(accessToken, "/v2/post/publish/status/fetch/", statusSchema, { publish_id: publishId }); + const status = response.data.status.toUpperCase(); + const failed = status === "FAILED" || status.includes("FAIL"); + const succeeded = ["PUBLISH_COMPLETE", "PUBLISHED", "PUBLICLY_AVAILABLE", "SEND_TO_USER_INBOX"].some((value) => status.includes(value)); + const isFinal = failed || succeeded; + const ids = response.data.publicly_available_post_id ?? response.data.publicaly_available_post_id ?? []; + + return { + status: response.data.status, + failReason: response.data.fail_reason ?? null, + publiclyAvailablePostIds: ids.map(String), + uploadedBytes: response.data.uploaded_bytes ?? null, + downloadedBytes: response.data.downloaded_bytes ?? null, + isFinal, + succeeded, + }; +} + +export async function cancelTikTokPublish(accessToken: string, publishId: string) { + const response = await callTikTok(accessToken, "/v2/post/publish/cancel/", z.object({ error: errorSchema }), { publish_id: publishId }); + return response.error.code === "ok"; +} From 8206c1fe3acc9ec348ee0be2e0fafe6bd8670e2a Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:15:59 +0100 Subject: [PATCH 03/43] feat(tokmetric): expose scoped TikTok connector credentials safely --- src/lib/tokmetric/oauth/connectors.ts | 56 ++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/lib/tokmetric/oauth/connectors.ts b/src/lib/tokmetric/oauth/connectors.ts index 8148d4da..36fa4012 100644 --- a/src/lib/tokmetric/oauth/connectors.ts +++ b/src/lib/tokmetric/oauth/connectors.ts @@ -4,7 +4,7 @@ import { decryptCredential, encryptCredential } from "./crypto"; import { getTikTokOAuthConfig, isPlatformApprovalRequired, providerScopes, validateTikTokOAuthConfig, type TikTokEnvironment, type TokMetricConnectorProvider } from "./config"; import { refreshTikTokToken, revokeTikTokToken, safeTokenMetadata, type TikTokTokenResponse } from "./client"; -interface StoredTikTokCredential { +export interface StoredTikTokCredential { accessToken: string; refreshToken?: string; expiresAt?: string; @@ -108,6 +108,60 @@ async function loadEnvironmentCredential(connectorId: string, environment: TikTo return { ref, stored: decryptCredential(ref.secretRef) }; } +export async function getAuthorizedTikTokCredential(input: { + workspaceId: string; + connectorId: string; + requiredScope: "video.publish" | "video.upload" | "user.info.basic"; + actorId?: string; + correlationId: string; +}) { + const connector = await db.connector.findFirst({ + where: { + id: input.connectorId, + workspaceId: input.workspaceId, + provider: "TIKTOK_CONTENT_POSTING_API", + state: "CONNECTED", + disabledAt: null, + }, + }); + if (!connector) throw new TokMetricError(409, "CONNECTOR_NOT_READY", "A connected TikTok Content Posting connector is required."); + + const config = getTikTokOAuthConfig(); + let loaded = await loadEnvironmentCredential(connector.id, config.environment); + const expiresAt = loaded.stored.expiresAt ? new Date(loaded.stored.expiresAt).getTime() : null; + const expiresSoon = expiresAt !== null && expiresAt <= Date.now() + 60_000; + + if (expiresSoon) { + if (!loaded.stored.refreshToken) { + await db.connector.update({ where: { id: connector.id }, data: { state: "TOKEN_EXPIRED", lastHealthAt: new Date() } }); + throw new TokMetricError(409, "REAUTHORIZATION_REQUIRED", "TikTok authorization has expired and must be renewed."); + } + const token = await refreshTikTokToken(loaded.stored.refreshToken); + await persistAuthorizedConnector({ + workspaceId: input.workspaceId, + actorId: input.actorId, + provider: connector.provider, + token, + correlationId: input.correlationId, + existingCredential: loaded.stored, + }); + loaded = await loadEnvironmentCredential(connector.id, config.environment); + } + + const granted = new Set([...connector.grantedScopes, ...loaded.stored.grantedScopes]); + if (!granted.has(input.requiredScope)) { + throw new TokMetricError(403, "TIKTOK_SCOPE_NOT_AUTHORIZED", `TikTok scope ${input.requiredScope} is required.`); + } + + return { + connector, + accessToken: loaded.stored.accessToken, + environment: loaded.stored.environment, + externalAccountId: loaded.stored.externalAccountId ?? connector.externalAccountId, + grantedScopes: [...granted], + }; +} + export async function refreshConnector(input: { workspaceId: string; connectorId: string; actorId?: string; correlationId: string }) { const connector = await db.connector.findFirst({ where: { id: input.connectorId, workspaceId: input.workspaceId } }); if (!connector) throw new TokMetricError(404, "CONNECTOR_NOT_FOUND", "Connector was not found."); From 567117b1e5db3b804f57ec86a2df955a61c44952 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:16:17 +0100 Subject: [PATCH 04/43] feat(tokmetric): add explicit production and sandbox publishing gates --- src/lib/tokmetric/publishing/gates.ts | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/lib/tokmetric/publishing/gates.ts diff --git a/src/lib/tokmetric/publishing/gates.ts b/src/lib/tokmetric/publishing/gates.ts new file mode 100644 index 00000000..7c1fce3b --- /dev/null +++ b/src/lib/tokmetric/publishing/gates.ts @@ -0,0 +1,29 @@ +import { TokMetricError } from "@/lib/tokmetric/security"; +import { getTikTokOAuthConfig } from "@/lib/tokmetric/oauth/config"; + +export function getTokMetricPublishingGate() { + const environment = getTikTokOAuthConfig().environment; + const productionEnabled = process.env.TOKMETRIC_LIVE_PUBLISHING_ENABLED === "true"; + const sandboxEnabled = process.env.TOKMETRIC_SANDBOX_PUBLISHING_ENABLED === "true"; + const enabled = productionEnabled || (environment === "sandbox" && sandboxEnabled); + + return { + environment, + productionEnabled, + sandboxEnabled, + enabled, + mode: productionEnabled ? "production" : environment === "sandbox" && sandboxEnabled ? "sandbox" : "disabled", + } as const; +} + +export function enforceTokMetricPublishingGate() { + const gate = getTokMetricPublishingGate(); + if (!gate.enabled) { + throw new TokMetricError( + 423, + "TIKTOK_PUBLISHING_DISABLED", + "TikTok publishing is disabled. Enable the explicit production or sandbox publishing gate first.", + ); + } + return gate; +} From 0a76b5aa038863fa0adbde76967afc020e7038b4 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:17:20 +0100 Subject: [PATCH 05/43] feat(tokmetric): add governed TikTok video publishing service --- src/lib/tokmetric/publishing/service.ts | 405 ++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 src/lib/tokmetric/publishing/service.ts diff --git a/src/lib/tokmetric/publishing/service.ts b/src/lib/tokmetric/publishing/service.ts new file mode 100644 index 00000000..4d3b50df --- /dev/null +++ b/src/lib/tokmetric/publishing/service.ts @@ -0,0 +1,405 @@ +import { Prisma, type TokMetricPublishState } from "@prisma/client"; +import { db } from "@/lib/db"; +import type { SessionPayload } from "@/lib/auth"; +import { + contentHash, + emitDomainEvent, + emitTokMetricAudit, + enforceEmergencyLocks, + TokMetricError, +} from "@/lib/tokmetric/security"; +import { getAuthorizedTikTokCredential } from "@/lib/tokmetric/oauth/connectors"; +import { getTikTokOAuthConfig } from "@/lib/tokmetric/oauth/config"; +import { enforceTokMetricPublishingGate, getTokMetricPublishingGate } from "./gates"; +import { + cancelTikTokPublish, + fetchTikTokPublishStatus, + initializeTikTokDirectPost, + queryTikTokCreatorInfo, + type TikTokDirectPostInput, +} from "./tiktok"; +import { + TIKTOK_VIDEO_MIME_TYPES, + calculateTikTokChunkPlan, + type TikTokPrivacyLevel, + type TikTokVideoMimeType, + type TikTokVideoSource, +} from "./types"; + +function toInputJson(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value ?? {})) as Prisma.InputJsonValue; +} + +async function addJobAttempt(publishJobId: string, state: TokMetricPublishState, metadata: unknown) { + const attempt = await db.jobAttempt.count({ where: { publishJobId } }) + 1; + await db.jobAttempt.create({ + data: { + publishJobId, + attempt, + state, + safeMetadata: toInputJson(metadata), + }, + }); +} + +export function validateVerifiedMediaUrl(rawUrl: string) { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new TokMetricError(400, "INVALID_VIDEO_URL", "Video URL must be a valid absolute URL."); + } + + if (url.protocol !== "https:" || url.username || url.password || url.hash) { + throw new TokMetricError(400, "INVALID_VIDEO_URL", "Video URL must use HTTPS without credentials or fragments."); + } + + const allowedHosts = (process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS ?? "") + .split(",") + .map((host) => host.trim().toLowerCase()) + .filter(Boolean); + if (allowedHosts.length === 0) { + throw new TokMetricError(423, "VERIFIED_MEDIA_HOSTS_NOT_CONFIGURED", "PULL_FROM_URL is disabled until a verified media domain is configured."); + } + + const hostname = url.hostname.toLowerCase(); + const allowed = allowedHosts.some((host) => hostname === host || hostname.endsWith(`.${host}`)); + if (!allowed) { + throw new TokMetricError(403, "VIDEO_URL_NOT_VERIFIED", "Video URL is not hosted on an approved TikTok URL property."); + } + + return url; +} + +export async function getVideoPublishingContext(session: SessionPayload) { + const privileged = ["admin", "super_admin", "internal"].includes(session.role); + const workspaces = await db.workspace.findMany({ + where: privileged ? {} : { members: { some: { userId: session.userId, status: "active" } } }, + orderBy: { updatedAt: "desc" }, + take: 20, + select: { + id: true, + name: true, + slug: true, + globalEmergencyLock: true, + publishingDisabled: true, + connectors: { + where: { provider: "TIKTOK_CONTENT_POSTING_API", state: "CONNECTED", disabledAt: null }, + select: { id: true, displayName: true, externalAccountId: true, grantedScopes: true, state: true }, + }, + contents: { + where: { state: "APPROVED", currentVersionId: { not: null } }, + orderBy: { updatedAt: "desc" }, + take: 50, + select: { id: true, title: true, currentVersionId: true, state: true }, + }, + publishJobs: { + orderBy: { updatedAt: "desc" }, + take: 20, + select: { + id: true, + contentId: true, + connectorId: true, + internalState: true, + externalState: true, + externalRequestId: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }); + + return { + gate: getTokMetricPublishingGate(), + verifiedMediaHosts: (process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS ?? "").split(",").map((value) => value.trim()).filter(Boolean), + workspaces, + }; +} + +export async function getCreatorInfoForPublishing(input: { + workspaceId: string; + connectorId: string; + actorId: string; + correlationId: string; +}) { + const credential = await getAuthorizedTikTokCredential({ + ...input, + requiredScope: "video.publish", + }); + const creator = await queryTikTokCreatorInfo(credential.accessToken); + await emitTokMetricAudit({ + workspaceId: input.workspaceId, + actorId: input.actorId, + action: "tokmetric.tiktok.creator_info", + entityType: "connector", + entityId: input.connectorId, + correlationId: input.correlationId, + outcome: "success", + sourceChannel: "website", + metadata: { creatorUsername: creator.creatorUsername, privacyLevelOptions: creator.privacyLevelOptions }, + }); + return creator; +} + +export type InitializeVideoPublishInput = { + workspaceId: string; + contentId: string; + connectorId: string; + actorId: string; + correlationId: string; + idempotencyKey: string; + title: string; + privacyLevel: TikTokPrivacyLevel; + disableComment: boolean; + disableDuet: boolean; + disableStitch: boolean; + videoCoverTimestampMs?: number; + brandContentToggle: boolean; + brandOrganicToggle: boolean; + isAigc: boolean; + source: TikTokVideoSource; + file?: { name: string; mimeType: TikTokVideoMimeType; size: number; durationSec?: number }; + videoUrl?: string; + consentToUpload: boolean; + rightsConfirmed: boolean; + musicRightsConfirmed: boolean; + processingNoticeAccepted: boolean; +}; + +async function assertApprovedContent(workspaceId: string, contentId: string) { + const content = await db.content.findFirst({ where: { id: contentId, workspaceId } }); + if (!content?.currentVersionId || content.state !== "APPROVED") { + throw new TokMetricError(409, "CONTENT_NOT_APPROVED", "Select an approved content version before publishing."); + } + const version = await db.contentVersion.findUnique({ where: { id: content.currentVersionId } }); + if (!version) throw new TokMetricError(409, "CONTENT_VERSION_MISSING", "The approved content version was not found."); + const approval = await db.approvalRequest.findFirst({ + where: { contentVersionId: version.id, objectHash: version.objectHash, state: "APPROVED" }, + orderBy: { updatedAt: "desc" }, + }); + if (!approval) throw new TokMetricError(409, "APPROVAL_REQUIRED", "A valid approval for this exact content version is required."); + return { content, version, approval }; +} + +export async function initializeVideoPublish(input: InitializeVideoPublishInput) { + await enforceEmergencyLocks(input.workspaceId, "publish"); + const gate = enforceTokMetricPublishingGate(); + if (!input.consentToUpload || !input.rightsConfirmed || !input.musicRightsConfirmed || !input.processingNoticeAccepted) { + throw new TokMetricError(400, "PUBLISHING_CONSENT_REQUIRED", "All publishing consent and rights confirmations are required."); + } + + const { content, version } = await assertApprovedContent(input.workspaceId, input.contentId); + const credential = await getAuthorizedTikTokCredential({ + workspaceId: input.workspaceId, + connectorId: input.connectorId, + requiredScope: "video.publish", + actorId: input.actorId, + correlationId: input.correlationId, + }); + const creator = await queryTikTokCreatorInfo(credential.accessToken); + if (!creator.privacyLevelOptions.includes(input.privacyLevel)) { + throw new TokMetricError(400, "PRIVACY_LEVEL_NOT_AVAILABLE", "Select one of the privacy options currently available for this TikTok account."); + } + if (gate.mode === "sandbox" && input.privacyLevel !== "SELF_ONLY") { + throw new TokMetricError(400, "SANDBOX_REQUIRES_SELF_ONLY", "Sandbox and unaudited posting must use SELF_ONLY privacy."); + } + + let chunkPlan: ReturnType | undefined; + let verifiedUrl: URL | undefined; + if (input.source === "FILE_UPLOAD") { + if (!input.file || !TIKTOK_VIDEO_MIME_TYPES.includes(input.file.mimeType)) { + throw new TokMetricError(400, "UNSUPPORTED_VIDEO_TYPE", "Select an MP4, MOV, or WebM video."); + } + if (input.file.durationSec && input.file.durationSec > creator.maxVideoPostDurationSec) { + throw new TokMetricError(400, "VIDEO_TOO_LONG", `This TikTok account currently allows videos up to ${creator.maxVideoPostDurationSec} seconds.`); + } + try { + chunkPlan = calculateTikTokChunkPlan(input.file.size); + } catch (error) { + throw new TokMetricError(400, "INVALID_VIDEO_SIZE", error instanceof Error ? error.message : "Video size is invalid."); + } + } else { + if (!input.videoUrl) throw new TokMetricError(400, "VIDEO_URL_REQUIRED", "A verified HTTPS video URL is required."); + verifiedUrl = validateVerifiedMediaUrl(input.videoUrl); + } + + const requestHash = contentHash({ + contentVersionId: version.id, + connectorId: input.connectorId, + title: input.title, + privacyLevel: input.privacyLevel, + disableComment: input.disableComment, + disableDuet: input.disableDuet, + disableStitch: input.disableStitch, + videoCoverTimestampMs: input.videoCoverTimestampMs, + brandContentToggle: input.brandContentToggle, + brandOrganicToggle: input.brandOrganicToggle, + isAigc: input.isAigc, + source: input.source, + file: input.file, + videoUrl: verifiedUrl?.toString(), + }); + + const existing = await db.publishJob.findUnique({ + where: { workspaceId_idempotencyKey: { workspaceId: input.workspaceId, idempotencyKey: input.idempotencyKey } }, + }); + if (existing) { + if (existing.objectHash !== version.objectHash) { + throw new TokMetricError(409, "IDEMPOTENCY_CONFLICT", "This idempotency key belongs to a different content version."); + } + throw new TokMetricError(409, "PUBLISH_ALREADY_INITIALIZED", "This publishing request was already initialized. Refresh its status or start a new request."); + } + + const job = await db.publishJob.create({ + data: { + workspaceId: input.workspaceId, + connectorId: input.connectorId, + contentId: content.id, + contentVersionId: version.id, + requestedById: input.actorId, + idempotencyKey: input.idempotencyKey, + internalState: "RUNNING", + externalState: "NOT_SUBMITTED", + objectHash: version.objectHash, + }, + }); + + const postInput: TikTokDirectPostInput = { + title: input.title, + privacyLevel: input.privacyLevel, + disableComment: input.disableComment || creator.commentDisabled, + disableDuet: input.disableDuet || creator.duetDisabled, + disableStitch: input.disableStitch || creator.stitchDisabled, + videoCoverTimestampMs: input.videoCoverTimestampMs, + brandContentToggle: input.brandContentToggle, + brandOrganicToggle: input.brandOrganicToggle, + isAigc: input.isAigc, + source: input.source, + chunkPlan, + videoUrl: verifiedUrl?.toString(), + }; + + try { + const initialized = await initializeTikTokDirectPost(credential.accessToken, postInput); + const externalState = input.source === "FILE_UPLOAD" ? "UPLOAD_INITIALIZED" : "PROCESSING"; + const internalState = input.source === "FILE_UPLOAD" ? "RUNNING" : "WAITING_FOR_PLATFORM"; + await db.publishJob.update({ + where: { id: job.id }, + data: { externalRequestId: initialized.publishId, externalState, internalState }, + }); + await addJobAttempt(job.id, internalState, { + requestHash, + source: input.source, + privacyLevel: input.privacyLevel, + titleLength: input.title.length, + brandContentToggle: input.brandContentToggle, + brandOrganicToggle: input.brandOrganicToggle, + isAigc: input.isAigc, + chunkPlan, + fileName: input.file?.name, + verifiedMediaHost: verifiedUrl?.hostname, + publishId: initialized.publishId, + }); + await emitTokMetricAudit({ + workspaceId: input.workspaceId, + actorId: input.actorId, + action: "tokmetric.tiktok.publish_initialized", + entityType: "publish_job", + entityId: job.id, + correlationId: input.correlationId, + outcome: "success", + sourceChannel: "website", + metadata: { source: input.source, publishId: initialized.publishId, privacyLevel: input.privacyLevel }, + }); + await emitDomainEvent({ + workspaceId: input.workspaceId, + aggregateType: "publish_job", + aggregateId: job.id, + eventType: "TIKTOK_VIDEO_PUBLISH_INITIALIZED", + correlationId: input.correlationId, + metadata: { source: input.source, publishId: initialized.publishId }, + }); + + return { + jobId: job.id, + publishId: initialized.publishId, + uploadUrl: initialized.uploadUrl, + chunkPlan: chunkPlan ?? null, + source: input.source, + processingNotice: "TikTok processing may take several minutes. Keep this page open to follow the status.", + }; + } catch (error) { + await db.publishJob.update({ where: { id: job.id }, data: { internalState: "FAILED", externalState: "FAILED" } }); + await addJobAttempt(job.id, "FAILED", { requestHash, source: input.source, errorCode: error instanceof TokMetricError ? error.code : "UNKNOWN" }); + throw error; + } +} + +export async function markVideoUploadComplete(input: { workspaceId: string; jobId: string; actorId: string; correlationId: string }) { + const job = await db.publishJob.findFirst({ where: { id: input.jobId, workspaceId: input.workspaceId } }); + if (!job?.externalRequestId) throw new TokMetricError(404, "PUBLISH_JOB_NOT_FOUND", "Initialized publishing job was not found."); + if (job.externalState !== "UPLOAD_INITIALIZED" && job.externalState !== "UPLOADED") { + throw new TokMetricError(409, "UPLOAD_NOT_EXPECTED", "This publishing job is not waiting for a file upload."); + } + + const updated = await db.publishJob.update({ + where: { id: job.id }, + data: { internalState: "WAITING_FOR_PLATFORM", externalState: "UPLOADED" }, + }); + await addJobAttempt(job.id, "WAITING_FOR_PLATFORM", { event: "browser_file_upload_completed" }); + await emitTokMetricAudit({ workspaceId: input.workspaceId, actorId: input.actorId, action: "tokmetric.tiktok.upload_completed", entityType: "publish_job", entityId: job.id, correlationId: input.correlationId, outcome: "success", sourceChannel: "website" }); + return updated; +} + +export async function refreshVideoPublishStatus(input: { workspaceId: string; jobId: string; actorId: string; correlationId: string }) { + const job = await db.publishJob.findFirst({ where: { id: input.jobId, workspaceId: input.workspaceId } }); + if (!job?.connectorId || !job.externalRequestId) throw new TokMetricError(404, "PUBLISH_JOB_NOT_FOUND", "Publishing job or TikTok request ID was not found."); + const credential = await getAuthorizedTikTokCredential({ + workspaceId: input.workspaceId, + connectorId: job.connectorId, + requiredScope: "video.publish", + actorId: input.actorId, + correlationId: input.correlationId, + }); + const status = await fetchTikTokPublishStatus(credential.accessToken, job.externalRequestId); + + const internalState = status.isFinal ? (status.succeeded ? "COMPLETED" : "FAILED") : "WAITING_FOR_PLATFORM"; + const externalState = status.isFinal ? (status.succeeded ? "PUBLISHED_CONFIRMED" : "FAILED") : "PROCESSING"; + await db.publishJob.update({ where: { id: job.id }, data: { internalState, externalState } }); + await addJobAttempt(job.id, internalState, status); + await emitTokMetricAudit({ + workspaceId: input.workspaceId, + actorId: input.actorId, + action: "tokmetric.tiktok.status_refreshed", + entityType: "publish_job", + entityId: job.id, + correlationId: input.correlationId, + outcome: status.isFinal && !status.succeeded ? "failure" : "success", + sourceChannel: "website", + metadata: status, + }); + + return { jobId: job.id, internalState, externalState, ...status }; +} + +export async function cancelVideoPublish(input: { workspaceId: string; jobId: string; actorId: string; correlationId: string }) { + const job = await db.publishJob.findFirst({ where: { id: input.jobId, workspaceId: input.workspaceId } }); + if (!job?.connectorId || !job.externalRequestId) throw new TokMetricError(404, "PUBLISH_JOB_NOT_FOUND", "Publishing job or TikTok request ID was not found."); + const credential = await getAuthorizedTikTokCredential({ + workspaceId: input.workspaceId, + connectorId: job.connectorId, + requiredScope: "video.publish", + actorId: input.actorId, + correlationId: input.correlationId, + }); + await cancelTikTokPublish(credential.accessToken, job.externalRequestId); + const updated = await db.publishJob.update({ where: { id: job.id }, data: { internalState: "CANCELLED", externalState: "UNKNOWN" } }); + await addJobAttempt(job.id, "CANCELLED", { event: "cancel_requested" }); + await emitTokMetricAudit({ workspaceId: input.workspaceId, actorId: input.actorId, action: "tokmetric.tiktok.publish_cancelled", entityType: "publish_job", entityId: job.id, correlationId: input.correlationId, outcome: "success", sourceChannel: "website" }); + return updated; +} + +export function activeTikTokEnvironment() { + return getTikTokOAuthConfig().environment; +} From ffd8452a60533fb51f4b9d601e3a98ccb60ad575 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:17:47 +0100 Subject: [PATCH 06/43] feat(tokmetric): expose authenticated video publishing context --- .../api/tokmetric/publishing/context/route.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/app/api/tokmetric/publishing/context/route.ts diff --git a/src/app/api/tokmetric/publishing/context/route.ts b/src/app/api/tokmetric/publishing/context/route.ts new file mode 100644 index 00000000..9acea2d0 --- /dev/null +++ b/src/app/api/tokmetric/publishing/context/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { correlationId, requireTokMetricSession, tokMetricErrorResponse } from "@/lib/tokmetric/security"; +import { getVideoPublishingContext } from "@/lib/tokmetric/publishing/service"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const cid = correlationId(request); + try { + const session = await requireTokMetricSession(request); + const context = await getVideoPublishingContext(session); + return NextResponse.json( + { ok: true, correlationId: cid, data: context }, + { headers: { "Cache-Control": "no-store, max-age=0" } }, + ); + } catch (error) { + return tokMetricErrorResponse(error, cid); + } +} From 8dea5009c2f8bac90b2c57380a417586bb92a063 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:17:58 +0100 Subject: [PATCH 07/43] feat(tokmetric): add creator-info publishing endpoint --- .../publishing/creator-info/route.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/api/tokmetric/publishing/creator-info/route.ts diff --git a/src/app/api/tokmetric/publishing/creator-info/route.ts b/src/app/api/tokmetric/publishing/creator-info/route.ts new file mode 100644 index 00000000..926da5ce --- /dev/null +++ b/src/app/api/tokmetric/publishing/creator-info/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + correlationId, + parseJson, + requirePermission, + requireTokMetricSession, + requireWorkspaceAccess, + tokMetricErrorResponse, +} from "@/lib/tokmetric/security"; +import { getCreatorInfoForPublishing } from "@/lib/tokmetric/publishing/service"; + +const schema = z.object({ + workspaceId: z.string().min(1), + connectorId: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + const cid = correlationId(request); + try { + const session = await requireTokMetricSession(request); + const input = await parseJson(request, schema); + const membership = await requireWorkspaceAccess(input.workspaceId, session); + requirePermission(membership, "publish", "content"); + const creator = await getCreatorInfoForPublishing({ + ...input, + actorId: session.userId, + correlationId: cid, + }); + return NextResponse.json({ ok: true, correlationId: cid, data: creator }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return tokMetricErrorResponse(error, cid); + } +} From e4f08536481ceca3bac7ce44188748ae0735aa2f Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:18:14 +0100 Subject: [PATCH 08/43] feat(tokmetric): add governed TikTok video initialization endpoint --- .../api/tokmetric/publishing/init/route.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/app/api/tokmetric/publishing/init/route.ts diff --git a/src/app/api/tokmetric/publishing/init/route.ts b/src/app/api/tokmetric/publishing/init/route.ts new file mode 100644 index 00000000..0ad33592 --- /dev/null +++ b/src/app/api/tokmetric/publishing/init/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + correlationId, + parseJson, + requirePermission, + requireTokMetricSession, + requireWorkspaceAccess, + tokMetricErrorResponse, +} from "@/lib/tokmetric/security"; +import { + initializeVideoPublish, + type InitializeVideoPublishInput, +} from "@/lib/tokmetric/publishing/service"; + +const fileSchema = z.object({ + name: z.string().min(1).max(255), + mimeType: z.enum(["video/mp4", "video/quicktime", "video/webm"]), + size: z.number().int().positive().max(4 * 1024 * 1024 * 1024), + durationSec: z.number().positive().max(600).optional(), +}); + +const schema = z.object({ + workspaceId: z.string().min(1), + contentId: z.string().min(1), + connectorId: z.string().min(1), + title: z.string().max(2200), + privacyLevel: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]), + disableComment: z.boolean(), + disableDuet: z.boolean(), + disableStitch: z.boolean(), + videoCoverTimestampMs: z.number().int().nonnegative().optional(), + brandContentToggle: z.boolean(), + brandOrganicToggle: z.boolean(), + isAigc: z.boolean(), + source: z.enum(["FILE_UPLOAD", "PULL_FROM_URL"]), + file: fileSchema.optional(), + videoUrl: z.string().url().max(2048).optional(), + consentToUpload: z.literal(true), + rightsConfirmed: z.literal(true), + musicRightsConfirmed: z.literal(true), + processingNoticeAccepted: z.literal(true), +}); + +export async function POST(request: NextRequest) { + const cid = correlationId(request); + try { + const session = await requireTokMetricSession(request); + const body = await parseJson(request, schema); + const membership = await requireWorkspaceAccess(body.workspaceId, session); + requirePermission(membership, "publish", "content"); + const idempotencyKey = request.headers.get("idempotency-key"); + if (!idempotencyKey || idempotencyKey.length > 200) { + return NextResponse.json( + { ok: false, error: { code: "IDEMPOTENCY_KEY_REQUIRED", message: "A valid Idempotency-Key header is required.", correlationId: cid } }, + { status: 400 }, + ); + } + + const input: InitializeVideoPublishInput = { + workspaceId: body.workspaceId, + contentId: body.contentId, + connectorId: body.connectorId, + actorId: session.userId, + correlationId: cid, + idempotencyKey, + title: body.title, + privacyLevel: body.privacyLevel, + disableComment: body.disableComment, + disableDuet: body.disableDuet, + disableStitch: body.disableStitch, + videoCoverTimestampMs: body.videoCoverTimestampMs, + brandContentToggle: body.brandContentToggle, + brandOrganicToggle: body.brandOrganicToggle, + isAigc: body.isAigc, + source: body.source, + file: body.file, + videoUrl: body.videoUrl, + consentToUpload: body.consentToUpload, + rightsConfirmed: body.rightsConfirmed, + musicRightsConfirmed: body.musicRightsConfirmed, + processingNoticeAccepted: body.processingNoticeAccepted, + }; + + const result = await initializeVideoPublish(input); + return NextResponse.json({ ok: true, correlationId: cid, data: result }, { status: 201, headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return tokMetricErrorResponse(error, cid); + } +} From f57ddc97052e585b0f45f05fb1fe0b017b73fc53 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:18:23 +0100 Subject: [PATCH 09/43] feat(tokmetric): add video upload completion endpoint --- .../publishing/upload-complete/route.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/api/tokmetric/publishing/upload-complete/route.ts diff --git a/src/app/api/tokmetric/publishing/upload-complete/route.ts b/src/app/api/tokmetric/publishing/upload-complete/route.ts new file mode 100644 index 00000000..86950b41 --- /dev/null +++ b/src/app/api/tokmetric/publishing/upload-complete/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + correlationId, + parseJson, + requirePermission, + requireTokMetricSession, + requireWorkspaceAccess, + tokMetricErrorResponse, +} from "@/lib/tokmetric/security"; +import { markVideoUploadComplete } from "@/lib/tokmetric/publishing/service"; + +const schema = z.object({ + workspaceId: z.string().min(1), + jobId: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + const cid = correlationId(request); + try { + const session = await requireTokMetricSession(request); + const input = await parseJson(request, schema); + const membership = await requireWorkspaceAccess(input.workspaceId, session); + requirePermission(membership, "publish", "content"); + const job = await markVideoUploadComplete({ + ...input, + actorId: session.userId, + correlationId: cid, + }); + return NextResponse.json({ ok: true, correlationId: cid, data: job }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return tokMetricErrorResponse(error, cid); + } +} From ab9d1bc0eee48a6a3add9c41855dd310a0afdce1 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:18:49 +0100 Subject: [PATCH 10/43] feat(tokmetric): add TikTok publish status endpoint --- .../api/tokmetric/publishing/status/route.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/api/tokmetric/publishing/status/route.ts diff --git a/src/app/api/tokmetric/publishing/status/route.ts b/src/app/api/tokmetric/publishing/status/route.ts new file mode 100644 index 00000000..fd77ffba --- /dev/null +++ b/src/app/api/tokmetric/publishing/status/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + correlationId, + parseJson, + requirePermission, + requireTokMetricSession, + requireWorkspaceAccess, + tokMetricErrorResponse, +} from "@/lib/tokmetric/security"; +import { refreshVideoPublishStatus } from "@/lib/tokmetric/publishing/service"; + +const schema = z.object({ + workspaceId: z.string().min(1), + jobId: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + const cid = correlationId(request); + try { + const session = await requireTokMetricSession(request); + const input = await parseJson(request, schema); + const membership = await requireWorkspaceAccess(input.workspaceId, session); + requirePermission(membership, "publish", "content"); + const status = await refreshVideoPublishStatus({ + ...input, + actorId: session.userId, + correlationId: cid, + }); + return NextResponse.json({ ok: true, correlationId: cid, data: status }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return tokMetricErrorResponse(error, cid); + } +} From e6e8a3960954fea28352ed2eafbd4f9f1dd2867d Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:19:15 +0100 Subject: [PATCH 11/43] feat(tokmetric): add TikTok publish cancellation endpoint --- .../api/tokmetric/publishing/cancel/route.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/api/tokmetric/publishing/cancel/route.ts diff --git a/src/app/api/tokmetric/publishing/cancel/route.ts b/src/app/api/tokmetric/publishing/cancel/route.ts new file mode 100644 index 00000000..40d6da5e --- /dev/null +++ b/src/app/api/tokmetric/publishing/cancel/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + correlationId, + parseJson, + requirePermission, + requireTokMetricSession, + requireWorkspaceAccess, + tokMetricErrorResponse, +} from "@/lib/tokmetric/security"; +import { cancelVideoPublish } from "@/lib/tokmetric/publishing/service"; + +const schema = z.object({ + workspaceId: z.string().min(1), + jobId: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + const cid = correlationId(request); + try { + const session = await requireTokMetricSession(request); + const input = await parseJson(request, schema); + const membership = await requireWorkspaceAccess(input.workspaceId, session); + requirePermission(membership, "publish", "content"); + const job = await cancelVideoPublish({ + ...input, + actorId: session.userId, + correlationId: cid, + }); + return NextResponse.json({ ok: true, correlationId: cid, data: job }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return tokMetricErrorResponse(error, cid); + } +} From dd8caf9832e9e3bf6b89f2ab7d0a689ff11c97fa Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 6 Jul 2026 00:20:28 +0100 Subject: [PATCH 12/43] feat(tokmetric): add functional TikTok video publishing interface --- .../tokmetric/TokMetricVideoPublisher.tsx | 525 ++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 src/components/tokmetric/TokMetricVideoPublisher.tsx diff --git a/src/components/tokmetric/TokMetricVideoPublisher.tsx b/src/components/tokmetric/TokMetricVideoPublisher.tsx new file mode 100644 index 00000000..6486b5d5 --- /dev/null +++ b/src/components/tokmetric/TokMetricVideoPublisher.tsx @@ -0,0 +1,525 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + Loader2, + RefreshCw, + Send, + Square, + UploadCloud, +} from "lucide-react"; +import { + chunkByteRange, + type TikTokChunkPlan, + type TikTokCreatorInfo, + type TikTokPrivacyLevel, + type TikTokPublishStatus, + type TikTokVideoSource, +} from "@/lib/tokmetric/publishing/types"; + +type PublishingContext = { + gate: { + environment: "sandbox" | "production"; + productionEnabled: boolean; + sandboxEnabled: boolean; + enabled: boolean; + mode: "production" | "sandbox" | "disabled"; + }; + verifiedMediaHosts: string[]; + workspaces: Array<{ + id: string; + name: string; + slug: string; + globalEmergencyLock: boolean; + publishingDisabled: boolean; + connectors: Array<{ + id: string; + displayName: string; + externalAccountId: string | null; + grantedScopes: string[]; + state: string; + }>; + contents: Array<{ + id: string; + title: string; + currentVersionId: string | null; + state: string; + }>; + publishJobs: Array<{ + id: string; + contentId: string | null; + connectorId: string | null; + internalState: string; + externalState: string; + externalRequestId: string | null; + createdAt: string; + updatedAt: string; + }>; + }>; +}; + +type InitResult = { + jobId: string; + publishId: string; + uploadUrl: string | null; + chunkPlan: TikTokChunkPlan | null; + source: TikTokVideoSource; + processingNotice: string; +}; + +type ApiEnvelope = { + ok: boolean; + data?: T; + error?: { code: string; message: string }; +}; + +async function api(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + cache: "no-store", + }); + const payload = await response.json().catch(() => null) as ApiEnvelope | null; + if (!response.ok || !payload?.ok || payload.data === undefined) { + throw new Error(payload?.error?.message ?? "The request could not be completed."); + } + return payload.data; +} + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function getVideoDuration(file: File): Promise { + return new Promise((resolve) => { + const objectUrl = URL.createObjectURL(file); + const video = document.createElement("video"); + const finish = (value?: number) => { + URL.revokeObjectURL(objectUrl); + video.remove(); + resolve(value); + }; + video.preload = "metadata"; + video.onloadedmetadata = () => finish(Number.isFinite(video.duration) ? video.duration : undefined); + video.onerror = () => finish(undefined); + video.src = objectUrl; + }); +} + +export function TokMetricVideoPublisher() { + const [context, setContext] = useState(null); + const [loadingContext, setLoadingContext] = useState(true); + const [workspaceId, setWorkspaceId] = useState(""); + const [connectorId, setConnectorId] = useState(""); + const [contentId, setContentId] = useState(""); + const [creator, setCreator] = useState(null); + const [source, setSource] = useState("FILE_UPLOAD"); + const [file, setFile] = useState(null); + const [durationSec, setDurationSec] = useState(); + const [videoUrl, setVideoUrl] = useState(""); + const [title, setTitle] = useState(""); + const [privacyLevel, setPrivacyLevel] = useState("SELF_ONLY"); + const [disableComment, setDisableComment] = useState(false); + const [disableDuet, setDisableDuet] = useState(false); + const [disableStitch, setDisableStitch] = useState(false); + const [brandContentToggle, setBrandContentToggle] = useState(false); + const [brandOrganicToggle, setBrandOrganicToggle] = useState(false); + const [isAigc, setIsAigc] = useState(false); + const [consentToUpload, setConsentToUpload] = useState(false); + const [rightsConfirmed, setRightsConfirmed] = useState(false); + const [musicRightsConfirmed, setMusicRightsConfirmed] = useState(false); + const [processingNoticeAccepted, setProcessingNoticeAccepted] = useState(false); + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState(0); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [activeJob, setActiveJob] = useState<{ jobId: string; publishId: string } | null>(null); + const [latestStatus, setLatestStatus] = useState<(TikTokPublishStatus & { internalState: string; externalState: string }) | null>(null); + + const workspace = useMemo( + () => context?.workspaces.find((item) => item.id === workspaceId) ?? null, + [context, workspaceId], + ); + + async function loadContext() { + setLoadingContext(true); + setError(""); + try { + const data = await api("/api/tokmetric/publishing/context"); + setContext(data); + const first = data.workspaces[0]; + if (first) { + setWorkspaceId((current) => current || first.id); + setConnectorId((current) => current || first.connectors[0]?.id || ""); + setContentId((current) => current || first.contents[0]?.id || ""); + } + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Unable to load publishing context."); + } finally { + setLoadingContext(false); + } + } + + useEffect(() => { + void loadContext(); + }, []); + + function selectWorkspace(nextWorkspaceId: string) { + setWorkspaceId(nextWorkspaceId); + const next = context?.workspaces.find((item) => item.id === nextWorkspaceId); + setConnectorId(next?.connectors[0]?.id ?? ""); + setContentId(next?.contents[0]?.id ?? ""); + setCreator(null); + setActiveJob(null); + setLatestStatus(null); + } + + async function queryCreator() { + if (!workspaceId || !connectorId) return; + setBusy(true); + setError(""); + setMessage("Querying the latest TikTok creator settings…"); + try { + const data = await api("/api/tokmetric/publishing/creator-info", { + method: "POST", + body: JSON.stringify({ workspaceId, connectorId }), + }); + setCreator(data); + const preferred = context?.gate.mode === "sandbox" && data.privacyLevelOptions.includes("SELF_ONLY") + ? "SELF_ONLY" + : data.privacyLevelOptions[0]; + if (preferred) setPrivacyLevel(preferred); + setDisableComment(data.commentDisabled); + setDisableDuet(data.duetDisabled); + setDisableStitch(data.stitchDisabled); + setMessage(`Connected as ${data.creatorNickname} (@${data.creatorUsername}).`); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Creator settings could not be loaded."); + setMessage(""); + } finally { + setBusy(false); + } + } + + async function selectFile(nextFile: File | null) { + setFile(nextFile); + setDurationSec(undefined); + if (nextFile) { + const duration = await getVideoDuration(nextFile); + setDurationSec(duration); + } + } + + async function uploadFileToTikTok(uploadUrl: string, plan: TikTokChunkPlan, selectedFile: File) { + for (let index = 0; index < plan.totalChunkCount; index += 1) { + const range = chunkByteRange(plan, index); + const chunk = selectedFile.slice(range.start, range.end + 1, selectedFile.type); + const response = await fetch(uploadUrl, { + method: "PUT", + headers: { + "Content-Type": selectedFile.type, + "Content-Range": `bytes ${range.start}-${range.end}/${plan.videoSize}`, + }, + body: chunk, + }); + if (![200, 201, 206].includes(response.status)) { + throw new Error(`TikTok rejected video chunk ${index + 1} with HTTP ${response.status}.`); + } + setProgress(Math.round(((index + 1) / plan.totalChunkCount) * 100)); + } + } + + async function pollStatus(jobId: string) { + for (let attempt = 0; attempt < 60; attempt += 1) { + const status = await api( + "/api/tokmetric/publishing/status", + { method: "POST", body: JSON.stringify({ workspaceId, jobId }) }, + ); + setLatestStatus(status); + setMessage(`TikTok status: ${status.status}. Processing can take several minutes.`); + if (status.isFinal) return status; + await sleep(5000); + } + throw new Error("TikTok is still processing the video. Use Refresh status to continue checking."); + } + + async function startPublish() { + if (!creator) { + setError("Query the TikTok creator settings before publishing."); + return; + } + if (!workspaceId || !connectorId || !contentId) { + setError("Select a workspace, connected account, and approved content item."); + return; + } + if (source === "FILE_UPLOAD" && !file) { + setError("Select a video file."); + return; + } + if (source === "PULL_FROM_URL" && !videoUrl) { + setError("Enter a video URL from a verified TikTok URL property."); + return; + } + + setBusy(true); + setError(""); + setProgress(0); + setLatestStatus(null); + setMessage("Initializing the TikTok publishing request…"); + const idempotencyKey = crypto.randomUUID(); + + try { + const initialized = await api("/api/tokmetric/publishing/init", { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + body: JSON.stringify({ + workspaceId, + contentId, + connectorId, + title, + privacyLevel, + disableComment, + disableDuet, + disableStitch, + brandContentToggle, + brandOrganicToggle, + isAigc, + source, + file: file ? { name: file.name, mimeType: file.type, size: file.size, durationSec } : undefined, + videoUrl: source === "PULL_FROM_URL" ? videoUrl : undefined, + consentToUpload, + rightsConfirmed, + musicRightsConfirmed, + processingNoticeAccepted, + }), + }); + setActiveJob({ jobId: initialized.jobId, publishId: initialized.publishId }); + + if (initialized.source === "FILE_UPLOAD") { + if (!file || !initialized.uploadUrl || !initialized.chunkPlan) { + throw new Error("TikTok did not return a complete file-upload session."); + } + setMessage("Uploading the video directly to TikTok…"); + await uploadFileToTikTok(initialized.uploadUrl, initialized.chunkPlan, file); + await api("/api/tokmetric/publishing/upload-complete", { + method: "POST", + body: JSON.stringify({ workspaceId, jobId: initialized.jobId }), + }); + } + + setMessage(initialized.processingNotice); + const status = await pollStatus(initialized.jobId); + setMessage(status.succeeded + ? "TikTok confirmed the publishing workflow completed." + : `TikTok could not publish the video: ${status.failReason ?? status.status}`); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Publishing failed."); + } finally { + setBusy(false); + } + } + + async function refreshStatus() { + if (!activeJob) return; + setBusy(true); + setError(""); + try { + const status = await api( + "/api/tokmetric/publishing/status", + { method: "POST", body: JSON.stringify({ workspaceId, jobId: activeJob.jobId }) }, + ); + setLatestStatus(status); + setMessage(`TikTok status: ${status.status}.`); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Status refresh failed."); + } finally { + setBusy(false); + } + } + + async function cancelPublish() { + if (!activeJob) return; + setBusy(true); + setError(""); + try { + await api("/api/tokmetric/publishing/cancel", { + method: "POST", + body: JSON.stringify({ workspaceId, jobId: activeJob.jobId }), + }); + setMessage("Cancellation was requested from TikTok."); + setLatestStatus(null); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : "Cancellation failed."); + } finally { + setBusy(false); + } + } + + if (loadingContext) { + return ( +
+ Loading video publishing controls… +
+ ); + } + + if (!context) { + return ( +
+ Sign in with an authorized GEM Enterprise account to use the live publishing workflow. +
+ ); + } + + const blocked = !context.gate.enabled || workspace?.globalEmergencyLock || workspace?.publishingDisabled; + const allConfirmed = consentToUpload && rightsConfirmed && musicRightsConfirmed && processingNoticeAccepted; + + return ( +
+
+
+

Live review workflow

+

Publish a real video through TikTok

+

+ Creator settings are queried immediately before posting. The video is sent only after you select privacy and interaction settings and confirm upload, ownership, music rights, and processing consent. +

+
+
+ {blocked ? : } + {context.gate.mode.toUpperCase()} · {blocked ? "Publishing blocked" : "Publishing enabled"} +
+
+ +
+ + + +
+ +
+ + {creator && Posting account: {creator.creatorNickname} (@{creator.creatorUsername}) · max {creator.maxVideoPostDurationSec}s} +
+ +
+
+
+ {(["FILE_UPLOAD", "PULL_FROM_URL"] as TikTokVideoSource[]).map((value) => ( + + ))} +
+ + {source === "FILE_UPLOAD" ? ( + + ) : ( + + )} + +