diff --git a/.vercelignore b/.vercelignore index 47993be2..a3c7bf52 100644 --- a/.vercelignore +++ b/.vercelignore @@ -17,6 +17,7 @@ supabase/functions/gem-auth-gateway/* supabase/functions/gem-admin-write/* !supabase/functions/gem-admin-write/index.ts +!supabase/functions/gem-admin-write/tokmetric-command.ts supabase/functions/gem-tokmetric-gpt-gateway/* !supabase/functions/gem-tokmetric-gpt-gateway/index.ts diff --git a/src/__tests__/password-recovery-gateway.test.ts b/src/__tests__/password-recovery-gateway.test.ts index c6933a71..b2585bd5 100644 --- a/src/__tests__/password-recovery-gateway.test.ts +++ b/src/__tests__/password-recovery-gateway.test.ts @@ -69,12 +69,12 @@ describe("canonical password recovery and session authority", () => { expect(resetPageSource).not.toContain("searchParams.get(\"token\")") }) - it("uses the gateway only for credential verification and issues a canonical GEM session", () => { + it("validates and preserves gateway-issued session authority", () => { expect(loginRouteSource).toContain("loginWithGateway(email, password)") - expect(loginRouteSource).toContain("gateway_credential_verification") - expect(loginRouteSource).toContain("signSession(sessionPayload)") - expect(loginRouteSource).toContain("setSessionCookie(response, token)") - expect(loginRouteSource).not.toContain("wrapGatewayToken") - expect(gatewaySource).toContain("loginWithGateway") + expect(loginRouteSource).toContain("validGatewaySession(result.session)") + expect(loginRouteSource).toContain('session.authSource === "supabase_gateway"') + expect(loginRouteSource).toContain("setSessionCookie(response, wrapGatewayToken(token))") + expect(gatewaySource).toContain("wrapGatewayToken") + expect(gatewaySource).toContain("verifyGatewaySession") }) -}) \ No newline at end of file +}) diff --git a/src/__tests__/tokmetric-activation-approval-safety.test.ts b/src/__tests__/tokmetric-activation-approval-safety.test.ts new file mode 100644 index 00000000..a3fbe344 --- /dev/null +++ b/src/__tests__/tokmetric-activation-approval-safety.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +function source(path: string) { + return readFileSync(join(process.cwd(), path), "utf8"); +} + +describe("TokMetric activation approval safety", () => { + const command = source( + "supabase/functions/gem-admin-write/tokmetric-command.ts", + ); + const gateway = source("supabase/functions/gem-admin-write/index.ts"); + + it("requires an explicit operation at application and gateway layers", () => { + const route = source("src/app/api/tokmetric/command/route.ts"); + expect(route).toContain("TokMetric command operation is required."); + expect(command).toContain( + 'requiredText(input.body.operation, "operation", 3, 100)', + ); + expect(command).toContain('"INVALID_OPERATION"'); + expect(gateway).toContain('action === "tokmetric_command"'); + }); + + it("requires compliance evidence before approval", () => { + expect(command).toContain('"COMPLIANCE_REVIEW_REQUIRED"'); + expect(command).toContain('["PASS", "HUMAN_REVIEW_REQUIRED"]'); + expect(command).toContain('"COMPLIANCE_BLOCKED"'); + }); + + it("prevents self-approval and enforces the requested role", () => { + expect(command).toContain("approval.requestedById === actor.id"); + expect(command).toContain('"APPROVAL_SEPARATION_REQUIRED"'); + expect(command).toContain("roleCanDecide(actor.role, approval.requiredRole)"); + expect(command).toContain('"APPROVER_ROLE_REQUIRED"'); + }); + + it("binds approval to the current exact version and object hash", () => { + expect(command).toContain( + "content.currentVersionId !== approval.contentVersionId", + ); + expect(command).toContain("version.objectHash !== approval.objectHash"); + expect(command).toContain('"APPROVAL_VERSION_MISMATCH"'); + expect(command).toContain('"APPROVAL_HASH_MISMATCH"'); + }); + + it("requires a connected posting connector and authorized publishing scope", () => { + expect(command).toContain('"TIKTOK_CONTENT_POSTING_API"'); + expect(command).toContain('scopes.has("video.publish")'); + expect(command).toContain('scopes.has("video.upload")'); + expect(command).toContain('"CONTENT_POSTING_SCOPE_NOT_AUTHORIZED"'); + expect(command).toContain('"LIVE_PUBLISHING_GATE_DISABLED"'); + }); + + it("enforces session revocation for every administrator command", () => { + expect(gateway).toContain("validSessionVersion(sessionVersion)"); + expect(gateway).toContain("user.sessionVersion !== sessionVersion"); + expect(gateway).toContain('"SESSION_REVOKED"'); + }); + + it("never performs an external TikTok write", () => { + expect(command).toContain("externalActionTaken: false"); + expect(command).not.toContain("open.tiktokapis.com"); + expect(gateway).not.toContain("open.tiktokapis.com"); + }); +}); diff --git a/src/__tests__/tokmetric-activation-command-center.test.ts b/src/__tests__/tokmetric-activation-command-center.test.ts new file mode 100644 index 00000000..4352de47 --- /dev/null +++ b/src/__tests__/tokmetric-activation-command-center.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +function source(path: string) { + return readFileSync(join(process.cwd(), path), "utf8"); +} + +describe("TokMetric activation command center", () => { + it("retains the approved administrator gateway and TokMetric module", () => { + const ignore = source(".vercelignore"); + expect(ignore).toContain("!supabase/functions/gem-admin-write/"); + expect(ignore).toContain("!supabase/functions/gem-admin-write/index.ts"); + expect(ignore).toContain( + "!supabase/functions/gem-admin-write/tokmetric-command.ts", + ); + }); + + it("requires a versioned GEM administrator session", () => { + const gateway = source("supabase/functions/gem-admin-write/index.ts"); + expect(gateway).toContain("sessionVersion"); + expect(gateway).toContain('"SESSION_REVOKED"'); + expect(gateway).toContain('payload.iss !== "gem-auth-gateway"'); + expect(gateway).toContain('payload.aud !== "gem-enterprise"'); + expect(gateway).toContain("ADMIN_ROLES.includes"); + }); + + it("keeps activation operations internal and fail closed", () => { + const command = source( + "supabase/functions/gem-admin-write/tokmetric-command.ts", + ); + expect(command).toContain('controlledWriteMode: "COMMAND_CENTER_ONLY"'); + expect(command).toContain("externalActionTaken: false"); + expect(command).toContain('"LIVE_PUBLISHING_GATE_DISABLED"'); + expect(command).not.toContain("PUBLISHED_CONFIRMED"); + expect(command).not.toContain("open.tiktokapis.com"); + }); + + it("supports the complete controlled activation sequence", () => { + const command = source( + "supabase/functions/gem-admin-write/tokmetric-command.ts", + ); + for (const operation of [ + "snapshot", + "create_draft", + "create_version", + "run_review", + "request_approval", + "decide_approval", + "publish_preflight", + ]) { + expect(command).toContain(`operation === "${operation}"`); + } + const gateway = source("supabase/functions/gem-admin-write/index.ts"); + expect(gateway).toContain('action === "tokmetric_command"'); + expect(gateway).toContain("dispatchTokMetricCommand"); + }); + + it("proxies commands only through the HttpOnly GEM session", () => { + const route = source("src/app/api/tokmetric/command/route.ts"); + const client = source("src/lib/tokmetric/command-gateway.ts"); + expect(route).toContain("getGatewaySessionToken"); + expect(route).toContain("GEM_SESSION_REQUIRED"); + expect(route).toContain("invokeTokMetricCommandGateway"); + expect(route).toContain('"Cache-Control": "no-store, max-age=0"'); + expect(client).toContain("adminWriteGateway"); + expect(client).toContain('"tokmetric_command"'); + expect(client).not.toContain("DEFAULT_COMMAND_GATEWAY_URL"); + expect(client).not.toContain("GEM_SUPABASE_GATEWAY_ANON_KEY"); + expect(route).not.toContain("POSTGRES_PRISMA_URL"); + expect(route).not.toContain("SUPABASE_SERVICE_ROLE_KEY"); + }); + + it("preserves existing administrator, retention, and credential actions", () => { + const gateway = source("supabase/functions/gem-admin-write/index.ts"); + for (const action of [ + "update_user", + "retention_policy_list", + "retention_policy_create", + "retention_policy_action", + "tokmetric_credential_list", + "tokmetric_credential_issue_hash", + "tokmetric_credential_revoke", + ]) { + expect(gateway).toContain(`action === "${action}"`); + } + }); + + it("exposes real operator controls in the protected command center", () => { + const panel = source( + "src/components/tokmetric/TokMetricActivationPanel.tsx", + ); + const page = source("src/app/app/command-center/tokmetric/page.tsx"); + expect(panel).toContain('fetch("/api/tokmetric/command"'); + expect(panel).toContain("Connect Login Kit"); + expect(panel).toContain("Connect Posting API"); + expect(panel).toContain("Create internal content draft"); + expect(panel).toContain("Run review"); + expect(panel).toContain("Request approval"); + expect(panel).toContain("Preflight"); + expect(panel).toContain("External actions taken: none"); + expect(page).toContain(""); + }); +}); diff --git a/src/app/api/tokmetric/command/route.ts b/src/app/api/tokmetric/command/route.ts new file mode 100644 index 00000000..13004fe4 --- /dev/null +++ b/src/app/api/tokmetric/command/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getGatewaySessionToken } from "@/lib/auth"; +import { GatewayRequestError } from "@/lib/supabase-gateway"; +import { + invokeTokMetricCommandGateway, + type TokMetricCommandOperation, +} from "@/lib/tokmetric/command-gateway"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const OPERATIONS = new Set([ + "snapshot", + "create_draft", + "create_version", + "run_review", + "request_approval", + "decide_approval", + "publish_preflight", +]); + +function json(body: unknown, status = 200) { + return NextResponse.json(body, { + status, + headers: { + "Cache-Control": "no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + }, + }); +} + +async function requireToken() { + const token = await getGatewaySessionToken(); + if (!token) { + throw new GatewayRequestError( + 401, + "GEM_SESSION_REQUIRED", + "Authentication required.", + ); + } + return token; +} + +async function run( + operation: TokMetricCommandOperation, + payload: Record = {}, +) { + try { + const token = await requireToken(); + const result = await invokeTokMetricCommandGateway( + token, + operation, + payload, + ); + const created = operation === "create_draft" || operation === "request_approval"; + return json(result, created ? 201 : 200); + } catch (error) { + if (error instanceof GatewayRequestError) { + return json( + { error: error.message, code: error.code }, + error.statusCode, + ); + } + console.error("[tokmetric command route] internal error", { + name: error instanceof Error ? error.name : "unknown", + }); + return json( + { + error: "TokMetric command service is unavailable.", + code: "TOKMETRIC_COMMAND_UNAVAILABLE", + }, + 503, + ); + } +} + +export async function GET() { + return run("snapshot"); +} + +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return json( + { error: "Request body must be valid JSON.", code: "INVALID_JSON" }, + 400, + ); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return json( + { error: "TokMetric request is invalid.", code: "INVALID_REQUEST" }, + 400, + ); + } + const payload = body as Record; + if (typeof payload.operation !== "string") { + return json( + { + error: "TokMetric command operation is required.", + code: "INVALID_OPERATION", + }, + 400, + ); + } + const operation = payload.operation as TokMetricCommandOperation; + if (!OPERATIONS.has(operation)) { + return json( + { + error: "TokMetric command operation is invalid.", + code: "INVALID_OPERATION", + }, + 400, + ); + } + const commandPayload = { ...payload }; + delete commandPayload.operation; + return run(operation, commandPayload); +} diff --git a/src/app/app/command-center/tokmetric/page.tsx b/src/app/app/command-center/tokmetric/page.tsx index bbdc3d69..9a9df366 100644 --- a/src/app/app/command-center/tokmetric/page.tsx +++ b/src/app/app/command-center/tokmetric/page.tsx @@ -16,6 +16,7 @@ import { UsersRound, Video, } from "lucide-react"; +import { TokMetricActivationPanel } from "@/components/tokmetric/TokMetricActivationPanel"; import { TokMetricConnectorPanel } from "@/components/tokmetric/TokMetricConnectorPanel"; import { TokMetricGptCredentialManager } from "@/components/tokmetric/TokMetricGptCredentialManager"; @@ -86,6 +87,7 @@ const controlState = [ ["Native GEM Enterprise module", "READY"], ["Custom GPT Action contracts", "READY"], ["Bearer credential management", "READY"], + ["Command Center workflow gateway", "READY"], ["TikTok OAuth authorization", "AUTHORIZATION_REQUIRED"], ["Human approval enforcement", "ENABLED"], ["Live publishing", "LOCKED"], @@ -160,6 +162,8 @@ export default function TokMetricCommandCenterPage() { ))} + +
diff --git a/src/components/tokmetric/TokMetricActivationPanel.tsx b/src/components/tokmetric/TokMetricActivationPanel.tsx new file mode 100644 index 00000000..2f86a2e5 --- /dev/null +++ b/src/components/tokmetric/TokMetricActivationPanel.tsx @@ -0,0 +1,502 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + ClipboardCheck, + KeyRound, + Loader2, + PlayCircle, + RefreshCw, + Send, + ShieldCheck, + Video, +} from "lucide-react"; + +const WORKSPACE_ID = "ws_60488340ded94dcfab3b875ef9ae591c"; + +type Connector = { + id: string; + provider: string; + state: string; + displayName: string; + externalAccountId: string | null; + grantedScopes: string[]; + lastHealthAt: string | null; +}; + +type ContentRecord = { + id: string; + title: string; + state: string; + currentVersionId: string | null; + updatedAt: string; +}; + +type ApprovalRecord = { + id: string; + contentId: string | null; + contentVersionId: string | null; + requiredRole: string; + state: string; + expiresAt: string | null; +}; + +type Snapshot = { + ok: true; + workspace: { + id: string; + name: string; + globalEmergencyLock: boolean; + publishingDisabled: boolean; + advertisingDisabled: boolean; + shopWriteDisabled: boolean; + connectorDisabled: boolean; + }; + counts: { + connectors: number; + connectedConnectors: number; + contents: number; + approvedContents: number; + pendingApprovals: number; + publishJobs: number; + analyticsSnapshots: number; + activeGptCredentials: number; + }; + activation: { + productionActivation: "READY" | "BLOCKED"; + controlledWriteMode: string; + oauthEnabled: boolean; + livePublishingEnabled: boolean; + connectedPublishingConnector: boolean; + configMissing: string[]; + blockers: string[]; + }; + connectors: Connector[]; + contents: ContentRecord[]; + approvals: ApprovalRecord[]; + externalActionTaken: false; +}; + +type CommandError = { error?: string; code?: string }; + +async function readJson(response: Response): Promise { + return (await response.json().catch(() => ({}))) as T; +} + +function stateLabel(value: string) { + return value.replaceAll("_", " "); +} + +function StateBadge({ value }: { value: string }) { + const ready = ["READY", "CONNECTED", "APPROVED", "PASS"].includes(value); + return ( + + {ready ? ( + + ) : ( + + )} + {stateLabel(value)} + + ); +} + +export function TokMetricActivationPanel() { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [working, setWorking] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [title, setTitle] = useState(""); + const [script, setScript] = useState(""); + const [caption, setCaption] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch("/api/tokmetric/command", { + method: "GET", + cache: "no-store", + }); + const body = await readJson(response); + if (!response.ok || body.ok !== true) { + throw new Error(body.error || "TokMetric workspace could not be loaded."); + } + setSnapshot(body); + } catch (requestError) { + setSnapshot(null); + setError( + requestError instanceof Error + ? requestError.message + : "TokMetric workspace could not be loaded.", + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function command( + operation: string, + payload: Record, + successMessage: string, + ) { + setWorking(`${operation}:${String(payload.contentId || payload.approvalId || "global")}`); + setError(null); + setNotice(null); + try { + const response = await fetch("/api/tokmetric/command", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operation, ...payload }), + }); + const body = await readJson & CommandError>(response); + if (!response.ok) { + throw new Error(body.error || "TokMetric operation failed."); + } + setNotice(successMessage); + await load(); + return body; + } catch (requestError) { + setError( + requestError instanceof Error + ? requestError.message + : "TokMetric operation failed.", + ); + return null; + } finally { + setWorking(null); + } + } + + async function createDraft() { + if (title.trim().length < 1) { + setError("A draft title is required."); + return; + } + const result = await command( + "create_draft", + { + title: title.trim(), + script: script.trim() || undefined, + caption: caption.trim() || undefined, + }, + "Internal TokMetric draft created. No external TikTok action occurred.", + ); + if (result) { + setTitle(""); + setScript(""); + setCaption(""); + } + } + + function startOAuth(provider: string) { + const params = new URLSearchParams({ workspaceId: WORKSPACE_ID, provider }); + window.location.assign(`/api/tokmetric/oauth/start?${params.toString()}`); + } + + const publishingConnector = useMemo( + () => + snapshot?.connectors.find( + (connector) => connector.provider === "TIKTOK_CONTENT_POSTING_API", + ) ?? null, + [snapshot], + ); + + return ( +
+
+
+
+ +

Production activation console

+
+

+ Work through the controlled activation sequence using real workspace records. + Drafts, reviews, approvals, and preflight are internal. External TikTok + publishing remains blocked until every activation gate passes. +

+
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + {notice ? ( +
+ {notice} +
+ ) : null} + + {loading && !snapshot ? ( +
+ Loading production workspace… +
+ ) : snapshot ? ( +
+
+ {[ + ["Activation", snapshot.activation.productionActivation], + ["Connected connectors", String(snapshot.counts.connectedConnectors)], + ["Approved content", String(snapshot.counts.approvedContents)], + ["Pending approvals", String(snapshot.counts.pendingApprovals)], + ].map(([label, value]) => ( +
+

{label}

+
+ {label === "Activation" ? ( + + ) : ( +

{value}

+ )} +
+
+ ))} +
+ +
+
+
+ +

TikTok authorization

+
+

+ OAuth starts through GEM and stores tokens only in the encrypted backend. +

+
+ + +
+
+ {snapshot.connectors.length === 0 ? ( +

No authorized TikTok connectors.

+ ) : ( + snapshot.connectors.map((connector) => ( +
+
+

{connector.displayName}

+

{connector.provider}

+
+ +
+ )) + )} +
+
+ +
+
+
+
+ setTitle(event.target.value)} + placeholder="Draft title" + maxLength={200} + className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5 text-sm text-white outline-none focus:border-cyan-500/50" + /> +