diff --git a/.env.example b/.env.example
index 6c97c6d1..c87a178e 100644
--- a/.env.example
+++ b/.env.example
@@ -45,7 +45,13 @@ ADMIN_INITIAL_PASSWORD=replace-with-temporary-password
AUDIT_ENABLED=true
# TokMetric production gates and integrations (placeholders only)
+# Production Direct Post remains blocked unless this is explicitly true.
TOKMETRIC_LIVE_PUBLISHING_ENABLED=false
+# Sandbox Direct Post remains blocked unless this is explicitly true.
+TOKMETRIC_SANDBOX_PUBLISHING_ENABLED=false
+# Comma-separated domains already verified in TikTok Developer Portal → URL properties.
+# Required only for PULL_FROM_URL. Local files use FILE_UPLOAD directly to TikTok.
+TOKMETRIC_VERIFIED_MEDIA_HOSTS=gemcybersecurityassist.com,www.gemcybersecurityassist.com
TOKMETRIC_TIKTOK_CLIENT_KEY=
TOKMETRIC_TIKTOK_CLIENT_SECRET=
TOKMETRIC_TIKTOK_OAUTH_REDIRECT_URL=https://gemcybersecurityassist.com/api/tokmetric/oauth/callback
@@ -53,7 +59,7 @@ TOKMETRIC_TOKEN_ENCRYPTION_KEY=
TOKMETRIC_WEBHOOK_VERIFICATION_SECRET=
GPT_AUTH_TOKEN=
-# TikTok OAuth connector configuration (Phase 2 placeholders only)
+# TikTok OAuth connector configuration
TIKTOK_CLIENT_KEY=
TIKTOK_CLIENT_SECRET=
TIKTOK_REDIRECT_URI=https://gemcybersecurityassist.com/api/tokmetric/oauth/callback
diff --git a/docs/tokmetric/media-publishing.md b/docs/tokmetric/media-publishing.md
index 4d809b64..aef0b2b5 100644
--- a/docs/tokmetric/media-publishing.md
+++ b/docs/tokmetric/media-publishing.md
@@ -1,3 +1,98 @@
# TokMetric media publishing
-This document is maintained by the TokMetric eight-phase production program. Phase-specific implementation details will be expanded as each phase lands. Live TikTok publishing remains disabled by default across all phases until the production activation gate is satisfied.
+## Implemented flow
+
+TokMetric supports a governed end-to-end TikTok video publishing workflow:
+
+1. An authenticated operator selects a TokMetric workspace.
+2. The operator selects a connected TikTok Content Posting API account.
+3. The operator selects an approved content version.
+4. TokMetric queries TikTok's latest creator information before rendering publishing controls.
+5. The operator chooses one of the privacy levels returned for that creator.
+6. The operator configures comments, Duet, Stitch, commercial-content disclosure, and AI-generated-content disclosure.
+7. The operator explicitly confirms upload consent, video rights, music rights, and the TikTok processing notice.
+8. TokMetric initializes the Direct Post request through TikTok's official Content Posting API.
+9. A local MP4, MOV, or WebM file is uploaded directly from the browser to TikTok's temporary upload URL in sequential chunks.
+10. A server-hosted video can use `PULL_FROM_URL` only when its hostname is included in `TOKMETRIC_VERIFIED_MEDIA_HOSTS` and has been verified in the TikTok Developer Portal.
+11. TokMetric polls the official post-status endpoint and records the internal and external state of the publishing job.
+12. Operators can refresh the status until TikTok reports a final success or failure state.
+
+TikTok cancellation is restricted to an ongoing `PULL_FROM_URL` download and is best-effort. Local file uploads and posts that have already entered processing are not presented as cancellable.
+
+Access tokens, refresh tokens, client secrets, and TikTok upload URLs are never persisted in browser storage or returned through logs and audit metadata.
+
+## Activation gates
+
+Publishing remains fail-closed.
+
+### Sandbox review
+
+Set all of the following:
+
+```text
+TIKTOK_ENVIRONMENT=sandbox
+TOKMETRIC_TIKTOK_OAUTH_ENABLED=true
+TOKMETRIC_SANDBOX_PUBLISHING_ENABLED=true
+TOKMETRIC_LIVE_PUBLISHING_ENABLED=false
+```
+
+Sandbox publishing is restricted by the application to `SELF_ONLY` privacy. The workspace must also have `publishingDisabled=false`, no global emergency lock, an approved content version, and a valid connector carrying `video.publish`.
+
+### Production
+
+Set:
+
+```text
+TIKTOK_ENVIRONMENT=production
+TOKMETRIC_TIKTOK_OAUTH_ENABLED=true
+TOKMETRIC_LIVE_PUBLISHING_ENABLED=true
+TOKMETRIC_SANDBOX_PUBLISHING_ENABLED=false
+```
+
+Production activation should occur only after TikTok approves the required product and scopes and the app review is complete.
+
+## Transfer modes
+
+### FILE_UPLOAD
+
+Use this mode when the video is on the user's device. The browser uploads the video directly to TikTok using the temporary upload URL returned by TikTok. TokMetric plans sequential chunks that follow TikTok's 5 MB minimum, 64 MB normal maximum, 128 MB final-chunk allowance, 1,000-chunk maximum, and 4 GB video maximum.
+
+### PULL_FROM_URL
+
+Use this mode only when the video already exists on server-side storage. Configure a comma-separated allowlist:
+
+```text
+TOKMETRIC_VERIFIED_MEDIA_HOSTS=gemcybersecurityassist.com,www.gemcybersecurityassist.com
+```
+
+Every configured domain or URL prefix must also be verified in the TikTok Developer Portal. URLs must be HTTPS, must not contain credentials or fragments, and must not redirect. An ongoing URL download may be cancelled through the restricted cancellation route until TikTok reports that it is no longer cancellable.
+
+## Operational routes
+
+- `GET /api/tokmetric/publishing/context`
+- `POST /api/tokmetric/publishing/creator-info`
+- `POST /api/tokmetric/publishing/init`
+- `POST /api/tokmetric/publishing/upload-complete`
+- `POST /api/tokmetric/publishing/status`
+- `POST /api/tokmetric/publishing/cancel` — `PULL_FROM_URL` only
+
+All routes require an authenticated TokMetric session, workspace access, and the `publish:content` permission where a workspace role is present.
+
+## App review recording
+
+Record the working flow on `/tokmetric/publishing` while the TikTok app is in sandbox mode:
+
+1. Sign in to the GEM Enterprise demo account.
+2. Open TokMetric Publishing.
+3. Select the review workspace and connected sandbox TikTok account.
+4. Select an approved content item.
+5. Click **Query creator settings** and show the returned TikTok nickname and privacy options.
+6. Select a local video file.
+7. Edit the caption and choose `SELF_ONLY`.
+8. Show comment, Duet, Stitch, paid-partnership, own-business, and AI-generated-content controls.
+9. Check each consent and rights confirmation manually.
+10. Click **Send video to TikTok**.
+11. Show chunk-upload progress and the TikTok processing status.
+12. Open the TikTok sandbox account and show the resulting private post when processing completes.
+
+Do not submit a mockup-only recording. The review video should show real user interactions and a real sandbox publishing request.
diff --git a/docs/tokmetric/tiktok-app-review-submission.md b/docs/tokmetric/tiktok-app-review-submission.md
new file mode 100644
index 00000000..790a321c
--- /dev/null
+++ b/docs/tokmetric/tiktok-app-review-submission.md
@@ -0,0 +1,61 @@
+# TikTok App Review Submission — Alliance-Trust Hub / TokMetric
+
+## Copy for “Explain how each product and scope works”
+
+Use this text in the TikTok review form. It is under the 1,000-character limit:
+
+> Alliance-Trust Hub uses TikTok Login Kit so a user can connect their own TikTok account through TikTok OAuth. The `user.info.basic` scope is used to show the connected creator username and nickname. TokMetric uses the Content Posting API with `video.publish` only after the user selects an approved video, refreshes the creator settings, edits the caption, chooses one of TikTok’s returned privacy options, configures comments/Duet/Stitch and commercial or AI-content disclosures, confirms video and music rights, and expressly clicks “Send video to TikTok.” Local files upload directly from the user’s browser to TikTok’s temporary upload URL. The app then polls TikTok’s status endpoint and shows processing, success, or failure. TikTok passwords and access tokens are never displayed or stored in the browser. Sandbox demonstrations use `SELF_ONLY` privacy.
+
+## Products to select
+
+Select only the products demonstrated in the recording:
+
+- Login Kit
+- Content Posting API — Direct Post
+
+Do not select Display API, Share Kit, TikTok Shop, Business API, or advertising products unless the submitted recording demonstrates those products end to end.
+
+## Scopes to request
+
+- `user.info.basic`
+- `video.publish`
+
+Do not request `video.upload` for this Direct Post review flow. `video.upload` is used for the separate upload-to-inbox/draft experience and is not required by the implemented Direct Post workflow.
+
+## Demo video recording sequence
+
+Record one continuous MP4 or MOV video showing:
+
+1. The browser address bar displaying the verified GEM domain.
+2. Sign-in to the GEM Enterprise review account.
+3. Open `/tokmetric/publishing`.
+4. Select the review workspace.
+5. Select the connected TikTok sandbox account.
+6. Select an approved content record.
+7. Click **Query creator settings**.
+8. Show the returned TikTok nickname, username, maximum duration, and privacy options.
+9. Select a local MP4/MOV/WebM video.
+10. Edit the caption.
+11. Choose `SELF_ONLY` for sandbox review.
+12. Show the comments, Duet, Stitch, paid-partnership, own-business, and AI-generated-content controls.
+13. Manually check all four consent and rights confirmations.
+14. Click **Send video to TikTok**.
+15. Show upload progress.
+16. Show TikTok processing status in TokMetric.
+17. Open the authorized TikTok sandbox/test account and show the resulting private post after processing completes.
+18. Briefly show the public Privacy Policy and Terms of Service pages.
+
+The recording must show real clicks and a real sandbox API request. Do not submit only static mockups or narrated slides.
+
+## Before clicking “Submit for review”
+
+- The review account can sign in successfully.
+- The review workspace has `publishingDisabled=false`.
+- The content item has an approved current version and matching approval hash.
+- The Content Posting connector is `CONNECTED` and carries `user.info.basic` and `video.publish`.
+- `TIKTOK_ENVIRONMENT=sandbox`.
+- `TOKMETRIC_TIKTOK_OAUTH_ENABLED=true`.
+- `TOKMETRIC_SANDBOX_PUBLISHING_ENABLED=true`.
+- `TOKMETRIC_LIVE_PUBLISHING_ENABLED=false`.
+- The redirect URI exactly matches the TikTok Developer Portal value.
+- The uploaded demo file is MP4 or MOV and is no larger than 50 MB, matching the review form’s upload limit.
diff --git a/src/__tests__/tokmetric-video-publishing.test.ts b/src/__tests__/tokmetric-video-publishing.test.ts
new file mode 100644
index 00000000..b759ec32
--- /dev/null
+++ b/src/__tests__/tokmetric-video-publishing.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ calculateTikTokChunkPlan,
+ chunkByteRange,
+} from "@/lib/tokmetric/publishing/types";
+import { getTokMetricPublishingGate } from "@/lib/tokmetric/publishing/gates";
+import { validateVerifiedMediaUrl } from "@/lib/tokmetric/publishing/service";
+
+const originalEnv = { ...process.env };
+
+afterEach(() => {
+ process.env = { ...originalEnv };
+});
+
+describe("TikTok video upload planning", () => {
+ it("uploads files smaller than 5 MiB as one complete chunk", () => {
+ const plan = calculateTikTokChunkPlan(2 * 1024 * 1024);
+ expect(plan).toEqual({
+ videoSize: 2 * 1024 * 1024,
+ chunkSize: 2 * 1024 * 1024,
+ totalChunkCount: 1,
+ });
+ expect(chunkByteRange(plan, 0)).toEqual({
+ start: 0,
+ end: 2 * 1024 * 1024 - 1,
+ length: 2 * 1024 * 1024,
+ });
+ });
+
+ it("keeps a 64 MiB video as one valid chunk", () => {
+ const size = 64 * 1024 * 1024;
+ expect(calculateTikTokChunkPlan(size)).toEqual({
+ videoSize: size,
+ chunkSize: size,
+ totalChunkCount: 1,
+ });
+ });
+
+ it("uses at least two chunks immediately above 64 MiB", () => {
+ const size = 65 * 1024 * 1024;
+ const plan = calculateTikTokChunkPlan(size);
+ expect(plan.totalChunkCount).toBe(2);
+ expect(chunkByteRange(plan, 0).length).toBeLessThanOrEqual(64 * 1024 * 1024);
+ expect(chunkByteRange(plan, 1).end).toBe(size - 1);
+ });
+
+ it("uses sequential chunks and merges the trailing bytes into the final request", () => {
+ const videoSize = 150 * 1024 * 1024;
+ const plan = calculateTikTokChunkPlan(videoSize);
+ expect(plan.totalChunkCount).toBe(2);
+ expect(chunkByteRange(plan, 0).start).toBe(0);
+ const final = chunkByteRange(plan, 1);
+ expect(final.end).toBe(videoSize - 1);
+ expect(final.length).toBe(videoSize - plan.chunkSize);
+ });
+
+ it("rejects files larger than TikTok's 4 GB maximum", () => {
+ expect(() => calculateTikTokChunkPlan(4 * 1024 * 1024 * 1024 + 1)).toThrow(/4 GB/);
+ });
+});
+
+describe("TikTok publishing activation gates", () => {
+ it("does not let the production flag activate a sandbox environment", () => {
+ process.env.TIKTOK_ENVIRONMENT = "sandbox";
+ process.env.TOKMETRIC_LIVE_PUBLISHING_ENABLED = "true";
+ process.env.TOKMETRIC_SANDBOX_PUBLISHING_ENABLED = "false";
+ const gate = getTokMetricPublishingGate();
+ expect(gate.enabled).toBe(false);
+ expect(gate.configurationMismatch).toBe(true);
+ });
+
+ it("does not let the sandbox flag activate a production environment", () => {
+ process.env.TIKTOK_ENVIRONMENT = "production";
+ process.env.TOKMETRIC_LIVE_PUBLISHING_ENABLED = "false";
+ process.env.TOKMETRIC_SANDBOX_PUBLISHING_ENABLED = "true";
+ const gate = getTokMetricPublishingGate();
+ expect(gate.enabled).toBe(false);
+ expect(gate.configurationMismatch).toBe(true);
+ });
+
+ it("activates only the matching sandbox gate", () => {
+ process.env.TIKTOK_ENVIRONMENT = "sandbox";
+ process.env.TOKMETRIC_LIVE_PUBLISHING_ENABLED = "false";
+ process.env.TOKMETRIC_SANDBOX_PUBLISHING_ENABLED = "true";
+ expect(getTokMetricPublishingGate()).toMatchObject({
+ environment: "sandbox",
+ enabled: true,
+ mode: "sandbox",
+ });
+ });
+});
+
+describe("TikTok verified media URL controls", () => {
+ it("accepts HTTPS URLs on configured domains and subdomains", () => {
+ process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS = "gemcybersecurityassist.com";
+ expect(validateVerifiedMediaUrl("https://media.gemcybersecurityassist.com/videos/demo.mp4").hostname)
+ .toBe("media.gemcybersecurityassist.com");
+ });
+
+ it("rejects unverified domains and URL credentials", () => {
+ process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS = "gemcybersecurityassist.com";
+ expect(() => validateVerifiedMediaUrl("https://example.com/demo.mp4")).toThrow(/approved TikTok URL property/);
+ expect(() => validateVerifiedMediaUrl("https://user:pass@gemcybersecurityassist.com/demo.mp4")).toThrow(/without credentials/);
+ });
+
+ it("fails closed when no verified media hosts are configured", () => {
+ delete process.env.TOKMETRIC_VERIFIED_MEDIA_HOSTS;
+ expect(() => validateVerifiedMediaUrl("https://gemcybersecurityassist.com/demo.mp4")).toThrow(/disabled/);
+ });
+});
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..f48d3cea
--- /dev/null
+++ b/src/app/api/tokmetric/publishing/cancel/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import {
+ correlationId,
+ parseJson,
+ requirePermission,
+ requireTokMetricSession,
+ requireWorkspaceAccess,
+ tokMetricErrorResponse,
+} from "@/lib/tokmetric/security";
+import { cancelPullFromUrlPublish } from "@/lib/tokmetric/publishing/pullCancel";
+
+const schema = z.object({
+ workspaceId: z.string().min(1),
+ jobId: z.string().min(1),
+});
+
+type CancelPayload = {
+ workspaceId: string;
+ jobId: string;
+};
+
+export async function POST(request: NextRequest) {
+ const cid = correlationId(request);
+ try {
+ const session = await requireTokMetricSession(request);
+ const input = await parseJson(request, schema) as CancelPayload;
+ const membership = await requireWorkspaceAccess(input.workspaceId, session);
+ requirePermission(membership, "publish", "content");
+ const job = await cancelPullFromUrlPublish({
+ workspaceId: input.workspaceId,
+ jobId: input.jobId,
+ 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);
+ }
+}
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);
+ }
+}
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..fdd2e6e4
--- /dev/null
+++ b/src/app/api/tokmetric/publishing/creator-info/route.ts
@@ -0,0 +1,40 @@
+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),
+});
+
+type CreatorInfoPayload = {
+ workspaceId: string;
+ connectorId: string;
+};
+
+export async function POST(request: NextRequest) {
+ const cid = correlationId(request);
+ try {
+ const session = await requireTokMetricSession(request);
+ const input = await parseJson(request, schema) as CreatorInfoPayload;
+ const membership = await requireWorkspaceAccess(input.workspaceId, session);
+ requirePermission(membership, "publish", "content");
+ const creator = await getCreatorInfoForPublishing({
+ workspaceId: input.workspaceId,
+ connectorId: input.connectorId,
+ 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);
+ }
+}
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..e7080922
--- /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,
+ TokMetricError,
+ 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),
+});
+
+type VideoInitPayload = Omit<
+ InitializeVideoPublishInput,
+ "actorId" | "correlationId" | "idempotencyKey"
+>;
+
+export async function POST(request: NextRequest) {
+ const cid = correlationId(request);
+ try {
+ const session = await requireTokMetricSession(request);
+ const body = await parseJson(request, schema) as VideoInitPayload;
+ const membership = await requireWorkspaceAccess(body.workspaceId, session);
+ requirePermission(membership, "publish", "content");
+
+ if (body.brandContentToggle && body.privacyLevel === "SELF_ONLY") {
+ throw new TokMetricError(
+ 400,
+ "BRANDED_CONTENT_REQUIRES_VISIBLE_PRIVACY",
+ "Branded content cannot be posted with SELF ONLY visibility.",
+ );
+ }
+
+ 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 = {
+ ...body,
+ actorId: session.userId,
+ correlationId: cid,
+ idempotencyKey,
+ };
+
+ 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);
+ }
+}
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..530a9912
--- /dev/null
+++ b/src/app/api/tokmetric/publishing/status/route.ts
@@ -0,0 +1,40 @@
+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),
+});
+
+type PublishStatusPayload = {
+ workspaceId: string;
+ jobId: string;
+};
+
+export async function POST(request: NextRequest) {
+ const cid = correlationId(request);
+ try {
+ const session = await requireTokMetricSession(request);
+ const input = await parseJson(request, schema) as PublishStatusPayload;
+ const membership = await requireWorkspaceAccess(input.workspaceId, session);
+ requirePermission(membership, "publish", "content");
+ const status = await refreshVideoPublishStatus({
+ workspaceId: input.workspaceId,
+ jobId: input.jobId,
+ 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);
+ }
+}
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..e02ccd22
--- /dev/null
+++ b/src/app/api/tokmetric/publishing/upload-complete/route.ts
@@ -0,0 +1,40 @@
+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),
+});
+
+type UploadCompletePayload = {
+ workspaceId: string;
+ jobId: string;
+};
+
+export async function POST(request: NextRequest) {
+ const cid = correlationId(request);
+ try {
+ const session = await requireTokMetricSession(request);
+ const input = await parseJson(request, schema) as UploadCompletePayload;
+ const membership = await requireWorkspaceAccess(input.workspaceId, session);
+ requirePermission(membership, "publish", "content");
+ const job = await markVideoUploadComplete({
+ workspaceId: input.workspaceId,
+ jobId: input.jobId,
+ 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);
+ }
+}
diff --git a/src/app/tokmetric/publishing/page.tsx b/src/app/tokmetric/publishing/page.tsx
index 46cd0025..1aaa7da2 100644
--- a/src/app/tokmetric/publishing/page.tsx
+++ b/src/app/tokmetric/publishing/page.tsx
@@ -1,12 +1,56 @@
import type { Metadata } from "next";
-import { TokMetricWorkspacePage } from "@/components/tokmetric/TokMetricWorkspacePage";
+import Link from "next/link";
+import { ArrowLeft, ShieldCheck, Video } from "lucide-react";
+import { TokMetricVideoPublisher } from "@/components/tokmetric/TokMetricVideoPublisher";
export const metadata: Metadata = {
- title: "TokMetric Publishing Queue",
- description: "TokMetric publishing queue for controlled external submissions, internal job states, and platform confirmation tracking.",
+ title: "TokMetric Video Publishing",
+ description:
+ "Governed TikTok video publishing with creator settings, explicit consent, direct file transfer, status polling, and platform confirmation.",
alternates: { canonical: "/tokmetric/publishing" },
};
export default function Page() {
- return ;
+ return (
+
+
+
+
+
+ Back to TokMetric dashboard
+
+
+
+
+
+
+ TikTok Content Posting API
+
+
+ Governed TikTok video publishing
+
+
+ This operational page queries the connected creator, displays TikTok-provided privacy and interaction settings, requires explicit consent and rights confirmation, uploads the selected video, and tracks TikTok processing to a final status.
+
+ The selected video is transmitted only after the latest creator settings are loaded and every required post choice and consent is completed manually.
+
+ Posting to {creator.creatorNickname} (@{creator.creatorUsername}) · maximum video duration {creator.maxVideoPostDurationSec}s. No post setting has been preselected.
+