Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/manager/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,10 @@ WORKFLOW_API_KEY=
# API Authentication — optional in dev, required in production
MANAGER_API_KEY=

# Subtitle review editor
SUBTITLE_EDITOR_PUBLIC_URL=http://localhost:3003
SUBTITLE_EDITOR_ALLOWED_ORIGINS=http://localhost:3003
SUBTITLE_REVIEW_SESSION_SECRET=

# Public
NEXT_PUBLIC_WATCH_URL=
37 changes: 21 additions & 16 deletions apps/manager/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,24 @@ Local dev requires a Strapi user with role name exactly `Manager`. Create via St

## Environment variables (Doppler project: forge-manager)

| Variable | Description |
| ---------------------------- | ------------------------------------------------------------------------- |
| MUX_TOKEN_ID | Mux API token ID |
| MUX_TOKEN_SECRET | Mux API token secret |
| OPENROUTER_API_KEY | OpenRouter API key |
| ELEVENLABS_API_KEY | ElevenLabs API key for audio isolation (optional — enables audio cleanup) |
| RAILWAY_S3_ENDPOINT | Railway Object Storage endpoint (optional — local fallback) |
| RAILWAY_S3_REGION | Railway S3 region (default: auto) |
| RAILWAY_S3_BUCKET | Railway S3 bucket name (optional — triggers S3 mode) |
| RAILWAY_S3_ACCESS_KEY_ID | Railway S3 access key (optional) |
| RAILWAY_S3_SECRET_ACCESS_KEY | Railway S3 secret key (optional) |
| STRAPI_URL | URL of apps/cms (Railway internal) |
| STRAPI_API_TOKEN | Strapi API token (seeded in bootstrap) |
| WORKFLOW_API_KEY | workflow API key (optional, for production durability) |
| MANAGER_API_KEY | API key for external clients (optional in dev) |
| NEXT_PUBLIC_WATCH_URL | Public video watch URL (optional) |
| Variable | Description |
| ------------------------------------- | ------------------------------------------------------------------------- |
| MUX_TOKEN_ID | Mux API token ID |
| MUX_TOKEN_SECRET | Mux API token secret |
| OPENROUTER_API_KEY | OpenRouter API key |
| ELEVENLABS_API_KEY | ElevenLabs API key for audio isolation (optional — enables audio cleanup) |
| RAILWAY_S3_ENDPOINT | Railway Object Storage endpoint (optional — local fallback) |
| RAILWAY_S3_REGION | Railway S3 region (default: auto) |
| RAILWAY_S3_BUCKET | Railway S3 bucket name (optional — triggers S3 mode) |
| RAILWAY_S3_ACCESS_KEY_ID | Railway S3 access key (optional) |
| RAILWAY_S3_SECRET_ACCESS_KEY | Railway S3 secret key (optional) |
| STRAPI_URL | URL of apps/cms (Railway internal) |
| STRAPI_API_TOKEN | Strapi API token (seeded in bootstrap) |
| WORKFLOW_API_KEY | workflow API key (optional, for production durability) |
| MANAGER_API_KEY | API key for external clients (optional in dev) |
| ELEVENLABS_REQUEST_TIMEOUT_MS | ElevenLabs request timeout override (optional) |
| ELEVENLABS_SOURCE_DOWNLOAD_TIMEOUT_MS | ElevenLabs source download timeout override (optional) |
| SUBTITLE_EDITOR_PUBLIC_URL | Public subtitle editor app URL |
| SUBTITLE_EDITOR_ALLOWED_ORIGINS | Exact allowed origins for editor browser APIs |
| SUBTITLE_REVIEW_SESSION_SECRET | Dedicated HMAC secret for subtitle review edit sessions |
| NEXT_PUBLIC_WATCH_URL | Public video watch URL (optional) |
147 changes: 147 additions & 0 deletions apps/manager/src/app/api/jobs/[id]/subtitle-reviews/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { NextResponse } from "next/server"
import {
buildSubtitleReviewCorsHeaders,
getSubtitleReviewConfiguration,
} from "@/lib/subtitle-review-session"
import type { SubtitleReviewFailureReason } from "@/services/subtitleReview"

const NO_STORE_HEADERS = {
"Cache-Control": "private, no-store",
}

export function noStoreJson(
body: Record<string, unknown>,
init?: ResponseInit,
): NextResponse {
return NextResponse.json(body, {
...init,
headers: {
...NO_STORE_HEADERS,
...init?.headers,
},
})
}

export function requireSubtitleReviewConfigurationResponse(init?: {
cors?: Record<string, string>
}): NextResponse | null {
const configuration = getSubtitleReviewConfiguration()
if (configuration.ok) {
return null
}

return noStoreJson(
{
error: "subtitle_review_not_configured",
missing: configuration.missing,
},
{
status: 503,
headers: init?.cors,
},
)
}

export function requireEditorCorsHeaders(request: Request): Response | null {
const headers = buildSubtitleReviewCorsHeaders(request.headers.get("origin"))
const configurationError = requireSubtitleReviewConfigurationResponse({
cors: headers ?? { Vary: "Origin" },
})
if (configurationError) {
return configurationError
}

if (headers) {
return null
}

return NextResponse.json(
{ error: "Forbidden origin" },
{
status: 403,
headers: {
"Cache-Control": "private, no-store",
Vary: "Origin",
},
},
)
}

export function corsHeaders(request: Request): Record<string, string> {
return (
buildSubtitleReviewCorsHeaders(request.headers.get("origin")) ?? {
Vary: "Origin",
}
)
}

export function preflightResponse(request: Request): Response {
const headers = buildSubtitleReviewCorsHeaders(request.headers.get("origin"))
const configuration = getSubtitleReviewConfiguration()
if (!configuration.ok) {
return new Response(null, {
status: 503,
headers: {
...NO_STORE_HEADERS,
...(headers ?? { Vary: "Origin" }),
},
})
}

if (!headers) {
return new Response(null, {
status: 403,
headers: {
"Cache-Control": "private, no-store",
Vary: "Origin",
},
})
}

return new Response(null, {
status: 204,
headers: {
...NO_STORE_HEADERS,
...headers,
},
})
}

export function failureResponse(
reason: SubtitleReviewFailureReason,
init?: { latestArtifactKey?: string; cors?: Record<string, string> },
): NextResponse {
const statusByReason: Record<SubtitleReviewFailureReason, number> = {
job_not_found: 404,
artifact_not_found: 404,
invalid_artifact: 400,
invalid_launch: 401,
invalid_token: 401,
missing_playback: 422,
invalid_vtt: 422,
rate_limited: 429,
stale_base: 409,
persist_failed: 500,
}

return noStoreJson(
{
error: reason,
...(init?.latestArtifactKey
? { latestArtifactKey: init.latestArtifactKey }
: {}),
},
{
status: statusByReason[reason],
headers: init?.cors,
},
)
}

export function readBearerToken(request: Request): string | null {
const header = request.headers.get("authorization")
if (!header?.startsWith("Bearer ")) {
return null
}
return header.slice("Bearer ".length)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

const { saveRevisionMock } = vi.hoisted(() => ({
saveRevisionMock: vi.fn(),
}))

vi.mock("@/services/subtitleReview", () => ({
saveSubtitleReviewRevision: saveRevisionMock,
}))

import { POST } from "@/app/api/jobs/[id]/subtitle-reviews/revisions/route"

describe("POST /api/jobs/[id]/subtitle-reviews/revisions", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv(
"SUBTITLE_EDITOR_ALLOWED_ORIGINS",
"https://subtitles.forge.test",
)
vi.stubEnv("SUBTITLE_EDITOR_PUBLIC_URL", "https://subtitles.forge.test")
vi.stubEnv("SUBTITLE_REVIEW_SESSION_SECRET", "test-secret")
saveRevisionMock.mockResolvedValue({
ok: true,
status: "saved",
jobId: "job-1",
artifactKey: "subtitles-fr-reviewed-r0001",
reviewedArtifactKey: "subtitles-fr-reviewed-r0001",
revision: 1,
contentFingerprint: "content-fingerprint",
baseArtifactFingerprint: "base-fingerprint",
savedAt: "2026-04-12T12:00:00.000Z",
})
})

it("saves a reviewed revision from an allowed editor origin", async () => {
const response = await POST(
new Request(
"https://manager.test/api/jobs/job-1/subtitle-reviews/revisions",
{
method: "POST",
headers: {
Authorization: "Bearer edit-token",
"Content-Type": "application/json",
Origin: "https://subtitles.forge.test",
},
body: JSON.stringify({
baseArtifactFingerprint: "base-fingerprint",
clientSaveId: "save-1",
vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSalut\n",
}),
},
),
{ params: Promise.resolve({ id: "job-1" }) },
)

expect(response.status).toBe(201)
await expect(response.json()).resolves.toEqual({
status: "saved",
artifactKey: "subtitles-fr-reviewed-r0001",
reviewedArtifactKey: "subtitles-fr-reviewed-r0001",
revision: 1,
jobId: "job-1",
contentFingerprint: "content-fingerprint",
baseArtifactFingerprint: "base-fingerprint",
savedAt: "2026-04-12T12:00:00.000Z",
})
expect(saveRevisionMock).toHaveBeenCalledWith({
jobId: "job-1",
editToken: "edit-token",
baseArtifactFingerprint: "base-fingerprint",
clientSaveId: "save-1",
vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSalut\n",
})
})

it("returns conflict when the save base is stale", async () => {
saveRevisionMock.mockResolvedValue({
ok: false,
reason: "stale_base",
latestArtifactKey: "subtitles-fr-reviewed-r0002",
})

const response = await POST(
new Request(
"https://manager.test/api/jobs/job-1/subtitle-reviews/revisions",
{
method: "POST",
headers: {
Authorization: "Bearer edit-token",
"Content-Type": "application/json",
Origin: "https://subtitles.forge.test",
},
body: JSON.stringify({
clientSaveId: "save-1",
vtt: "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nSalut\n",
}),
},
),
{ params: Promise.resolve({ id: "job-1" }) },
)

expect(response.status).toBe(409)
await expect(response.json()).resolves.toEqual({
error: "stale_base",
latestArtifactKey: "subtitles-fr-reviewed-r0002",
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { z } from "zod"
import { saveSubtitleReviewRevision } from "@/services/subtitleReview"
import {
corsHeaders,
failureResponse,
noStoreJson,
preflightResponse,
readBearerToken,
requireEditorCorsHeaders,
} from "../response"

const requestBodySchema = z.object({
baseArtifactFingerprint: z.string().min(1).optional(),
clientSaveId: z.string().min(1),
vtt: z.string().min(1),
})

export function OPTIONS(request: Request) {
return preflightResponse(request)
}

export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const corsError = requireEditorCorsHeaders(request)
if (corsError) return corsError

const headers = corsHeaders(request)
const editToken = readBearerToken(request)
if (!editToken) {
return noStoreJson(
{ error: "Edit token required" },
{ status: 401, headers },
)
}

const parsedBody = requestBodySchema.safeParse(
await request.json().catch(() => null),
)
if (!parsedBody.success) {
return noStoreJson(
{ error: "Invalid revision request" },
{ status: 400, headers },
)
}

const { id } = await params
const result = await saveSubtitleReviewRevision({
jobId: id,
editToken,
baseArtifactFingerprint: parsedBody.data.baseArtifactFingerprint,
clientSaveId: parsedBody.data.clientSaveId,
vtt: parsedBody.data.vtt,
})

if (!result.ok) {
return failureResponse(result.reason, {
latestArtifactKey: result.latestArtifactKey,
cors: headers,
})
}

return noStoreJson(
{
status: result.status,
artifactKey: result.artifactKey,
reviewedArtifactKey: result.reviewedArtifactKey,
revision: result.revision,
jobId: result.jobId,
contentFingerprint: result.contentFingerprint,
baseArtifactFingerprint: result.baseArtifactFingerprint,
savedAt: result.savedAt,
},
{ status: result.status === "saved" ? 201 : 200, headers },
)
}
Loading
Loading