From 2b59bfa8cdd43ded2ff1c8f55561ce8d20125a86 Mon Sep 17 00:00:00 2001 From: jettwang Date: Sun, 25 Oct 2026 22:55:16 +0800 Subject: [PATCH] feat: add private accounts, project sharing, recycle bin and dual-era MCP discovery --- README.md | 2 + .../migrations/0004_ownership_recycle_bin.sql | 21 + apps/api/package.json | 5 +- apps/api/src/app.ts | 436 ++++-- apps/api/src/auth.ts | 140 +- apps/api/src/index.ts | 23 +- apps/api/src/mcp-http.ts | 196 +++ apps/api/src/mcp.ts | 385 ++++- apps/api/wrangler.jsonc | 29 +- apps/web/src/AccountMenu.tsx | 87 ++ apps/web/src/App.tsx | 16 +- apps/web/src/Projects.tsx | 114 +- apps/web/src/ResourceControls.tsx | 259 ++++ apps/web/src/TrashPage.tsx | 131 ++ apps/web/src/Workbench.tsx | 73 +- apps/web/src/routes.ts | 2 + apps/web/src/styles.css | 377 ++++- docs/accounts-and-trash.md | 46 + docs/architecture.md | 4 + docs/deployment.md | 4 + docs/mcp-discovery.md | 36 + docs/reference-authoring.md | 2 +- docs/roadmap.md | 12 + package-lock.json | 1266 ++++++++++++++++- package.json | 2 + packages/client/src/index.ts | 60 +- packages/domain/src/authoring.ts | 11 +- packages/domain/src/d1-r2-storage.ts | 110 +- packages/domain/src/index.ts | 2 + packages/domain/src/lifecycle.ts | 265 ++++ packages/domain/src/service.ts | 1022 ++++++++++--- packages/domain/src/storage.ts | 19 +- packages/domain/src/types.ts | 29 +- packages/protocol/src/types.ts | 2 + scripts/ui-test-server.ts | 52 +- tests/browser/account-trash.spec.ts | 118 ++ tests/e2e/06-http-and-mcp-parity.test.ts | 2 +- tests/e2e/07-lightweight-workbench.test.tsx | 1 + ...9-d1-r2-persistence-and-bootstrap.test.tsx | 289 +++- .../e2e/10-media-and-editor-recovery.test.tsx | 15 +- tests/e2e/11-project-navigation.test.tsx | 21 +- tests/e2e/12-mcp-oauth.test.ts | 9 +- tests/e2e/15-reference-integrity.test.ts | 2 +- tests/e2e/16-account-trash-isolation.test.ts | 481 +++++++ tests/e2e/17-mcp-discovery-transport.test.ts | 228 +++ tests/e2e/auth-fixture.ts | 59 + tests/e2e/helpers.ts | 31 +- 47 files changed, 5902 insertions(+), 594 deletions(-) create mode 100644 apps/api/migrations/0004_ownership_recycle_bin.sql create mode 100644 apps/api/src/mcp-http.ts create mode 100644 apps/web/src/AccountMenu.tsx create mode 100644 apps/web/src/ResourceControls.tsx create mode 100644 apps/web/src/TrashPage.tsx create mode 100644 docs/accounts-and-trash.md create mode 100644 docs/mcp-discovery.md create mode 100644 packages/domain/src/lifecycle.ts create mode 100644 tests/browser/account-trash.spec.ts create mode 100644 tests/e2e/16-account-trash-isolation.test.ts create mode 100644 tests/e2e/17-mcp-discovery-transport.test.ts create mode 100644 tests/e2e/auth-fixture.ts diff --git a/README.md b/README.md index 67bb333..cce9faf 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,5 @@ npm run test:browser ## License MIT + +账号默认私有、明确授权共享、退出登录及项目/编排回收站的权限、接口和迁移说明见 [用户与回收站](docs/accounts-and-trash.md)。 diff --git a/apps/api/migrations/0004_ownership_recycle_bin.sql b/apps/api/migrations/0004_ownership_recycle_bin.sql new file mode 100644 index 0000000..59be299 --- /dev/null +++ b/apps/api/migrations/0004_ownership_recycle_bin.sql @@ -0,0 +1,21 @@ +-- Ownership is server-derived. Legacy agent-created projects migrate only when +-- there is exactly one human writer; ambiguous projects remain quarantined. +ALTER TABLE projects ADD COLUMN owner_user_id TEXT; +UPDATE projects SET owner_user_id = CASE + WHEN instr(created_by, '@') > 0 THEN lower(trim(created_by)) + ELSE (SELECT CASE WHEN count(DISTINCT lower(trim(actor_id))) = 1 THEN min(lower(trim(actor_id))) ELSE NULL END + FROM project_members WHERE project_id = projects.id AND role = 'writer' AND instr(actor_id, '@') > 0) +END; +ALTER TABLE projects ADD COLUMN sharing_mode TEXT NOT NULL DEFAULT 'private' CHECK(sharing_mode IN ('private','members')); +UPDATE projects SET sharing_mode = 'members' WHERE EXISTS ( + SELECT 1 FROM project_members WHERE project_id = projects.id AND instr(actor_id,'@') > 0 AND lower(trim(actor_id)) <> owner_user_id +); +ALTER TABLE projects ADD COLUMN membership_revision INTEGER NOT NULL DEFAULT 0; +CREATE INDEX projects_owner_idx ON projects(owner_user_id); +CREATE TABLE resource_lifecycle ( + project_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('project','composition')), + id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY(project_id,kind,id) +); diff --git a/apps/api/package.json b/apps/api/package.json index 2b8e738..661ed8a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,8 +11,9 @@ "build": "tsc --noEmit" }, "dependencies": { - "@video-compose/protocol": "*", "@video-compose/domain": "*", - "hono": "^4.7.4" + "@video-compose/protocol": "*", + "hono": "^4.7.4", + "jose": "^6.2.13" } } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index aca4430..dd1616f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,7 +7,7 @@ import type { ActorIdentity } from "@video-compose/protocol"; import { Hono } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import { type AccessAuthConfig, extractActorFromHeaders } from "./auth.ts"; -import { handleMcpJsonRpc, type JsonRpcRequest } from "./mcp.ts"; +import { mcpHttp } from "./mcp-http.ts"; import { hashToken, InMemoryOAuthStore, @@ -28,6 +28,10 @@ function statusForDomainError(code: DomainErrorCode): ContentfulStatusCode { return 401; case "FORBIDDEN": return 403; + case "RESOURCE_TRASHED": + return 410; + case "PARENT_TRASHED": + return 409; case "NOT_FOUND": return 404; case "ALREADY_EXISTS": @@ -64,7 +68,7 @@ export function createApiApp( "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": - "Authorization, Content-Type, CF-Access-Client-Id, CF-Access-Client-Secret, x-agent-id", + "Authorization, Content-Type, CF-Access-Client-Id, CF-Access-Client-Secret, x-agent-id, MCP-Protocol-Version, Mcp-Method, Mcp-Name", }; async function resolveActor(headers: Headers): Promise { @@ -82,9 +86,11 @@ export function createApiApp( return { actorId: token.owner, actorKind: explicitAgentId ? "agent" : "human", + userId: token.owner.trim().toLowerCase(), }; } } + return null; } return extractActorFromHeaders(headers, authConfig); } @@ -114,26 +120,125 @@ export function createApiApp( }); // CORS preflight for machine & OAuth endpoints - app.options("/mcp", () => new Response(null, { status: 204, headers: corsHeaders })); - app.options("/oauth/*", () => new Response(null, { status: 204, headers: corsHeaders })); - app.options("/.well-known/*", () => new Response(null, { status: 204, headers: corsHeaders })); + app.options( + "/mcp", + () => new Response(null, { status: 204, headers: corsHeaders }), + ); + app.options( + "/oauth/*", + () => new Response(null, { status: 204, headers: corsHeaders }), + ); + app.options( + "/.well-known/*", + () => new Response(null, { status: 204, headers: corsHeaders }), + ); + + app.use("/v1/*", async (c, next) => { + await next(); + c.header("Cache-Control", "private, no-store"); + c.header("Vary", "Cookie, Authorization"); + }); + app.get("/v1/session", async (c) => { + const actor = await resolveActor(c.req.raw.headers); + if (!actor) + throw new DomainError("UNAUTHORIZED", "Authentication is required."); + return c.json({ actor, logoutUrl: "/logout" }); + }); + app.get( + "/logout", + () => + new Response(null, { + status: 302, + headers: { + Location: "/cdn-cgi/access/logout", + "Clear-Site-Data": '"cache", "storage"', + "Cache-Control": "no-store", + }, + }), + ); + app.get("/v1/trash", async (c) => + c.json({ + items: await domain.lifecycle.list(await resolveActor(c.req.raw.headers)), + }), + ); + app.delete("/v1/projects/:projectId", async (c) => + c.json( + await domain.lifecycle.change(await resolveActor(c.req.raw.headers), { + ...(await c.req.json()), + projectId: c.req.param("projectId"), + id: c.req.param("projectId"), + kind: "project", + action: "trash", + }), + ), + ); + app.post("/v1/projects/:projectId/restore", async (c) => + c.json( + await domain.lifecycle.change(await resolveActor(c.req.raw.headers), { + ...(await c.req.json()), + projectId: c.req.param("projectId"), + id: c.req.param("projectId"), + kind: "project", + action: "restore", + }), + ), + ); + app.delete("/v1/projects/:projectId/compositions/:compositionId", async (c) => + c.json( + await domain.lifecycle.change(await resolveActor(c.req.raw.headers), { + ...(await c.req.json()), + projectId: c.req.param("projectId"), + id: c.req.param("compositionId"), + kind: "composition", + action: "trash", + }), + ), + ); + app.post( + "/v1/projects/:projectId/compositions/:compositionId/restore-from-trash", + async (c) => + c.json( + await domain.lifecycle.change(await resolveActor(c.req.raw.headers), { + ...(await c.req.json()), + projectId: c.req.param("projectId"), + id: c.req.param("compositionId"), + kind: "composition", + action: "restore", + }), + ), + ); // RFC 9728 Protected Resource Metadata - const handleProtectedResource = (c: { req: { url: string }; json: (data: unknown, status: ContentfulStatusCode, headers: Record) => Response }) => { + const handleProtectedResource = (c: { + req: { url: string }; + json: ( + data: unknown, + status: ContentfulStatusCode, + headers: Record, + ) => Response; + }) => { const origin = new URL(c.req.url).origin; return c.json( { resource: `${origin}/mcp`, authorization_servers: [origin], - scopes_supported: ["video-compose.read", "video-compose.write", "video-compose.all"], + scopes_supported: [ + "video-compose.read", + "video-compose.write", + "video-compose.all", + ], bearer_methods_supported: ["header"], }, 200, corsHeaders, ); }; - app.get("/.well-known/oauth-protected-resource", (c) => handleProtectedResource(c)); - app.get("/.well-known/oauth-protected-resource/mcp", (c) => handleProtectedResource(c)); + app.get("/.well-known/oauth-protected-resource", (c) => + handleProtectedResource(c), + ); + app.get("/.well-known/oauth-protected-resource/mcp", (c) => + handleProtectedResource(c), + ); // RFC 8414 Authorization Server Metadata app.get("/.well-known/oauth-authorization-server", (c) => { @@ -153,7 +258,11 @@ export function createApiApp( "client_secret_basic", ], code_challenge_methods_supported: ["S256"], - scopes_supported: ["video-compose.read", "video-compose.write", "video-compose.all"], + scopes_supported: [ + "video-compose.read", + "video-compose.write", + "video-compose.all", + ], }, 200, corsHeaders, @@ -251,7 +360,8 @@ export function createApiApp( return c.text("Unknown or disabled client_id", 400); } const finalRedirectUri = - redirectUri || (client.redirectUris.length === 1 ? client.redirectUris[0] : ""); + redirectUri || + (client.redirectUris.length === 1 ? client.redirectUris[0] : ""); if (!finalRedirectUri || !client.redirectUris.includes(finalRedirectUri)) { return c.text("Invalid or unregistered redirect_uri", 400); } @@ -264,7 +374,10 @@ export function createApiApp( if (!codeChallenge || codeChallengeMethod !== "S256") { const u = new URL(finalRedirectUri); u.searchParams.set("error", "invalid_request"); - u.searchParams.set("error_description", "PKCE with code_challenge_method=S256 is required"); + u.searchParams.set( + "error_description", + "PKCE with code_challenge_method=S256 is required", + ); if (state) u.searchParams.set("state", state); return c.redirect(u.toString(), 302); } @@ -310,7 +423,12 @@ export function createApiApp( const decision = String(body.decision || "").trim(); const grant = await oauthStore.getGrant(grantId); - if (!grant || grant.expiresAt < Date.now() || grant.approved || grant.usedAt) { + if ( + !grant || + grant.expiresAt < Date.now() || + grant.approved || + grant.usedAt + ) { return c.text("Authorization request expired or already processed", 400); } if (!csrf || csrf !== grant.csrf) { @@ -318,13 +436,19 @@ export function createApiApp( } const actor = await resolveActor(c.req.raw.headers); if (!actor || actor.actorId !== grant.owner) { - return c.text("Please sign in as the owner who initiated the authorization", 403); + return c.text( + "Please sign in as the owner who initiated the authorization", + 403, + ); } const redirectUrl = new URL(grant.redirectUri); if (decision !== "allow") { redirectUrl.searchParams.set("error", "access_denied"); - redirectUrl.searchParams.set("error_description", "User denied authorization"); + redirectUrl.searchParams.set( + "error_description", + "User denied authorization", + ); if (grant.state) redirectUrl.searchParams.set("state", grant.state); return c.redirect(redirectUrl.toString(), 302); } @@ -346,7 +470,9 @@ export function createApiApp( let body: Record = {}; const contentType = c.req.header("content-type") || ""; if (contentType.includes("application/json")) { - body = ((await c.req.json().catch(() => ({}))) as Record) || {}; + body = + ((await c.req.json().catch(() => ({}))) as Record) || + {}; } else { const parsed = await c.req.parseBody().catch(() => ({})); for (const [k, v] of Object.entries(parsed)) { @@ -380,7 +506,10 @@ export function createApiApp( const client = await oauthStore.getClient(clientId); if (!client || client.disabled) { return c.json( - { error: "invalid_client", error_description: "Unknown or disabled client" }, + { + error: "invalid_client", + error_description: "Unknown or disabled client", + }, 401, corsHeaders, ); @@ -389,7 +518,10 @@ export function createApiApp( if (client.authMethod !== "none") { if (!clientSecret) { return c.json( - { error: "invalid_client", error_description: "Missing client_secret" }, + { + error: "invalid_client", + error_description: "Missing client_secret", + }, 401, corsHeaders, ); @@ -397,7 +529,10 @@ export function createApiApp( const hashedSecret = await hashToken(clientSecret); if (hashedSecret !== client.secretHash) { return c.json( - { error: "invalid_client", error_description: "Invalid client credentials" }, + { + error: "invalid_client", + error_description: "Invalid client credentials", + }, 401, corsHeaders, ); @@ -422,7 +557,12 @@ export function createApiApp( const codeHash = await hashToken(code); const grant = await oauthStore.getGrantByCodeHash(codeHash); - if (!grant || !grant.approved || grant.usedAt || grant.expiresAt < Date.now()) { + if ( + !grant || + !grant.approved || + grant.usedAt || + grant.expiresAt < Date.now() + ) { return c.json( { error: "invalid_grant", @@ -586,7 +726,8 @@ export function createApiApp( return c.json( { error: "unsupported_grant_type", - error_description: "Only authorization_code and refresh_token are supported", + error_description: + "Only authorization_code and refresh_token are supported", }, 400, corsHeaders, @@ -598,7 +739,9 @@ export function createApiApp( let body: Record = {}; const contentType = c.req.header("content-type") || ""; if (contentType.includes("application/json")) { - body = ((await c.req.json().catch(() => ({}))) as Record) || {}; + body = + ((await c.req.json().catch(() => ({}))) as Record) || + {}; } else { const parsed = await c.req.parseBody().catch(() => ({})); for (const [k, v] of Object.entries(parsed)) { @@ -614,31 +757,134 @@ export function createApiApp( }); // Remote MCP endpoint over authenticated Streamable HTTP - app.post("/mcp", async (c) => { - const actor = await resolveActor(c.req.raw.headers); - const rpc = (await c.req.json()) as JsonRpcRequest; - const response = await handleMcpJsonRpc(domain, actor, rpc); - const origin = new URL(c.req.url).origin; - if (response.error?.code === -32001) { - return c.json(response, 401, { - ...corsHeaders, - "WWW-Authenticate": `Bearer realm="video-compose", resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, - }); - } - return c.json(response, 200, corsHeaders); - }); + app.post("/mcp", async (c) => + mcpHttp( + c.req.raw, + domain, + await resolveActor(c.req.raw.headers), + corsHeaders, + ), + ); + app.on( + ["GET", "DELETE", "PUT", "PATCH"], + "/mcp", + () => + new Response(null, { + status: 405, + headers: { + ...corsHeaders, + Allow: "POST, OPTIONS", + "Cache-Control": "no-store", + }, + }), + ); - app.get("/v1/projects/:projectId/authoring/workflow", async c => c.json(await domain.authoring.workflow(await resolveActor(c.req.raw.headers), c.req.param("projectId"), c.req.query("language") === "en" ? "en" : "zh"))); - app.get("/v1/projects/:projectId/authoring/impact/:id/:revision", async c => c.json(await domain.authoring.impact(await resolveActor(c.req.raw.headers), c.req.param("projectId"), { id: c.req.param("id"), revision: Number(c.req.param("revision")) }))); - app.get("/v1/projects/:projectId/authoring/documents", async c => c.json(await domain.authoring.list(await resolveActor(c.req.raw.headers), c.req.param("projectId")))); - app.get("/v1/projects/:projectId/authoring/documents/:id", async c => c.json(await domain.authoring.get(await resolveActor(c.req.raw.headers), c.req.param("projectId"), c.req.param("id"), c.req.query("revision") === undefined ? undefined : Number(c.req.query("revision"))))); - app.post("/v1/projects/:projectId/authoring/documents", async c => c.json(await domain.authoring.put(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId") }), 201)); - app.post("/v1/projects/:projectId/authoring/documents/:id/review", async c => c.json(await domain.authoring.review(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId"), id: c.req.param("id") }))); - app.post("/v1/projects/:projectId/authoring/documents/:id/approve", async c => c.json(await domain.authoring.approve(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId"), id: c.req.param("id") }))); - app.post("/v1/projects/:projectId/authoring/validate", async c => c.json(await domain.authoring.validate(await resolveActor(c.req.raw.headers), c.req.param("projectId"), (await c.req.json()).document))); - app.post("/v1/projects/:projectId/authoring/prepare", async c => { const body = await c.req.json(); return c.json(await domain.authoring.prepare(await resolveActor(c.req.raw.headers), c.req.param("projectId"), body.request, body.workflowContext)); }); - app.get("/v1/projects/:projectId/authoring/evidence", async c => c.json(await domain.authoring.evidence(await resolveActor(c.req.raw.headers), c.req.param("projectId")))); - app.post("/v1/projects/:projectId/authoring/evidence", async c => c.json(await domain.authoring.recordEvidence(await resolveActor(c.req.raw.headers), { ...await c.req.json(), projectId: c.req.param("projectId") }), 201)); + app.get("/v1/projects/:projectId/authoring/workflow", async (c) => + c.json( + await domain.authoring.workflow( + await resolveActor(c.req.raw.headers), + c.req.param("projectId"), + c.req.query("language") === "en" ? "en" : "zh", + ), + ), + ); + app.get("/v1/projects/:projectId/authoring/impact/:id/:revision", async (c) => + c.json( + await domain.authoring.impact( + await resolveActor(c.req.raw.headers), + c.req.param("projectId"), + { id: c.req.param("id"), revision: Number(c.req.param("revision")) }, + ), + ), + ); + app.get("/v1/projects/:projectId/authoring/documents", async (c) => + c.json( + await domain.authoring.list( + await resolveActor(c.req.raw.headers), + c.req.param("projectId"), + ), + ), + ); + app.get("/v1/projects/:projectId/authoring/documents/:id", async (c) => + c.json( + await domain.authoring.get( + await resolveActor(c.req.raw.headers), + c.req.param("projectId"), + c.req.param("id"), + c.req.query("revision") === undefined + ? undefined + : Number(c.req.query("revision")), + ), + ), + ); + app.post("/v1/projects/:projectId/authoring/documents", async (c) => + c.json( + await domain.authoring.put(await resolveActor(c.req.raw.headers), { + ...(await c.req.json()), + projectId: c.req.param("projectId"), + }), + 201, + ), + ); + app.post( + "/v1/projects/:projectId/authoring/documents/:id/review", + async (c) => + c.json( + await domain.authoring.review(await resolveActor(c.req.raw.headers), { + ...(await c.req.json()), + projectId: c.req.param("projectId"), + id: c.req.param("id"), + }), + ), + ); + app.post( + "/v1/projects/:projectId/authoring/documents/:id/approve", + async (c) => + c.json( + await domain.authoring.approve(await resolveActor(c.req.raw.headers), { + ...(await c.req.json()), + projectId: c.req.param("projectId"), + id: c.req.param("id"), + }), + ), + ); + app.post("/v1/projects/:projectId/authoring/validate", async (c) => + c.json( + await domain.authoring.validate( + await resolveActor(c.req.raw.headers), + c.req.param("projectId"), + (await c.req.json()).document, + ), + ), + ); + app.post("/v1/projects/:projectId/authoring/prepare", async (c) => { + const body = await c.req.json(); + return c.json( + await domain.authoring.prepare( + await resolveActor(c.req.raw.headers), + c.req.param("projectId"), + body.request, + body.workflowContext, + ), + ); + }); + app.get("/v1/projects/:projectId/authoring/evidence", async (c) => + c.json( + await domain.authoring.evidence( + await resolveActor(c.req.raw.headers), + c.req.param("projectId"), + ), + ), + ); + app.post("/v1/projects/:projectId/authoring/evidence", async (c) => + c.json( + await domain.authoring.recordEvidence( + await resolveActor(c.req.raw.headers), + { ...(await c.req.json()), projectId: c.req.param("projectId") }, + ), + 201, + ), + ); // REST /v1/projects app.get("/v1/projects", async (c) => { @@ -669,13 +915,17 @@ export function createApiApp( app.get("/v1/projects/:projectId/overview", async (c) => { const actor = await resolveActor(c.req.raw.headers); - return c.json(await domain.getProjectOverview(actor, c.req.param("projectId"))); + return c.json( + await domain.getProjectOverview(actor, c.req.param("projectId")), + ); }); app.put("/v1/projects/:projectId/members/:targetActorId", async (c) => { const actor = await resolveActor(c.req.raw.headers); const body = await c.req.json(); const project = await domain.setProjectMemberRole(actor, { + baseRevision: body.baseRevision, + operationId: body.operationId, projectId: c.req.param("projectId"), targetActorId: c.req.param("targetActorId"), role: body.role ?? null, @@ -869,20 +1119,23 @@ export function createApiApp( return c.json({ snapshot }); }); - app.post("/v1/projects/:projectId/subjects/:subjectId/references", async (c) => { - const actor = await resolveActor(c.req.raw.headers); - const body = await c.req.json(); - const snapshot = await domain.attachSubjectReference(actor, { - workflowContext: body.workflowContext, - projectId: c.req.param("projectId"), - subjectId: c.req.param("subjectId"), - baseRevision: Number(body.baseRevision), - operationId: String(body.operationId ?? ""), - variant: String(body.variant ?? ""), - entry: body.entry, - }); - return c.json({ snapshot }, 201); - }); + app.post( + "/v1/projects/:projectId/subjects/:subjectId/references", + async (c) => { + const actor = await resolveActor(c.req.raw.headers); + const body = await c.req.json(); + const snapshot = await domain.attachSubjectReference(actor, { + workflowContext: body.workflowContext, + projectId: c.req.param("projectId"), + subjectId: c.req.param("subjectId"), + baseRevision: Number(body.baseRevision), + operationId: String(body.operationId ?? ""), + variant: String(body.variant ?? ""), + entry: body.entry, + }); + return c.json({ snapshot }, 201); + }, + ); app.post("/v1/projects/:projectId/subjects/:subjectId/approve", async (c) => { const actor = await resolveActor(c.req.raw.headers); @@ -956,20 +1209,23 @@ export function createApiApp( return c.json(result); }); - app.patch("/v1/projects/:projectId/compositions/:compositionId", async (c) => { - const actor = await resolveActor(c.req.raw.headers); - const body = await c.req.json(); - const result = await domain.patchComposition(actor, { - workflowContext: body.workflowContext, - projectId: c.req.param("projectId"), - compositionId: c.req.param("compositionId"), - baseRevision: Number(body.baseRevision), - operationId: String(body.operationId ?? ""), - operations: body.operations ?? [], - message: body.message, - }); - return c.json(result); - }); + app.patch( + "/v1/projects/:projectId/compositions/:compositionId", + async (c) => { + const actor = await resolveActor(c.req.raw.headers); + const body = await c.req.json(); + const result = await domain.patchComposition(actor, { + workflowContext: body.workflowContext, + projectId: c.req.param("projectId"), + compositionId: c.req.param("compositionId"), + baseRevision: Number(body.baseRevision), + operationId: String(body.operationId ?? ""), + operations: body.operations ?? [], + message: body.message, + }); + return c.json(result); + }, + ); app.get( "/v1/projects/:projectId/compositions/:compositionId/history", @@ -1011,7 +1267,10 @@ export function createApiApp( compositionId: c.req.param("compositionId"), revision: revParam ? Number(revParam) : undefined, format, - targetVersion: c.req.query("targetVersion") === "video-compose/v1" ? "video-compose/v1" : undefined, + targetVersion: + c.req.query("targetVersion") === "video-compose/v1" + ? "video-compose/v1" + : undefined, }); return c.json(exported); }, @@ -1043,17 +1302,20 @@ export function createApiApp( return c.json({ proposal }, 201); }); - app.post("/v1/projects/:projectId/proposals/:proposalId/approve", async (c) => { - const actor = await resolveActor(c.req.raw.headers); - const body = await c.req.json(); - const result = await domain.approveProposal(actor, { - workflowContext: body.workflowContext, - projectId: c.req.param("projectId"), - proposalId: c.req.param("proposalId"), - operationId: String(body.operationId ?? ""), - }); - return c.json(result); - }); + app.post( + "/v1/projects/:projectId/proposals/:proposalId/approve", + async (c) => { + const actor = await resolveActor(c.req.raw.headers); + const body = await c.req.json(); + const result = await domain.approveProposal(actor, { + workflowContext: body.workflowContext, + projectId: c.req.param("projectId"), + proposalId: c.req.param("proposalId"), + operationId: String(body.operationId ?? ""), + }); + return c.json(result); + }, + ); return { app, domain, oauthStore }; } diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 903e2cb..25f1ae1 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -1,88 +1,90 @@ +import { + createLocalJWKSet, + createRemoteJWKSet, + jwtVerify, + type JSONWebKeySet, +} from "jose"; import type { ActorIdentity } from "@video-compose/protocol"; export interface AccessAuthConfig { - allowedServiceTokens?: Record; + access?: { issuer: string; audience: string; jwks?: JSONWebKeySet }; + allowedServiceTokens?: Record< + string, + { secret: string; defaultActorId: string; ownerUserId: string } + >; } - -/** - * Extracts caller identity from Cloudflare Access headers. - * - Human SSO sessions supply `cf-access-authenticated-user-email` (and `cf-access-jwt-assertion`). - * - Headless Agent sessions supply `cf-access-client-id` + `cf-access-client-secret` (and optional `x-agent-id` for distinct agent auditing). - */ -export function extractActorFromHeaders( +const remoteKeys = new Map>(); +const localKeys = new WeakMap< + JSONWebKeySet, + ReturnType +>(); +const normalize = (s: string) => s.trim().toLowerCase(); +/** Only cryptographically verified Access claims or explicitly configured machine credentials create identities. */ +export async function extractActorFromHeaders( headers: Headers, config?: AccessAuthConfig, -): ActorIdentity | null { - const userEmail = headers.get("cf-access-authenticated-user-email")?.trim(); - const cookieHeader = headers.get("cookie") ?? ""; - const cfAuthCookieMatch = cookieHeader.match( - /(?:^|;\s*)CF_Authorization=([^;]+)/, - ); - const jwtAssertion = - headers.get("cf-access-jwt-assertion")?.trim() || - cfAuthCookieMatch?.[1]?.trim(); - if (userEmail && jwtAssertion) { - return { - actorId: userEmail, - actorKind: "human", - }; - } - +): Promise { const clientId = headers.get("cf-access-client-id")?.trim(); const clientSecret = headers.get("cf-access-client-secret")?.trim(); if (clientId && clientSecret) { - if (config?.allowedServiceTokens) { - const entry = config.allowedServiceTokens[clientId]; - if (!entry || entry.secret !== clientSecret) { - return null; - } - const explicitAgentId = headers.get("x-agent-id")?.trim(); - return { - actorId: explicitAgentId || entry.defaultActorId, - actorKind: "agent", - }; - } - const explicitAgentId = headers.get("x-agent-id")?.trim(); + const entry = config?.allowedServiceTokens?.[clientId]; + if ( + !entry || + !entry.ownerUserId?.trim() || + !entry.defaultActorId?.trim() || + entry.secret !== clientSecret + ) + return null; return { - actorId: explicitAgentId || `agent:${clientId}`, + actorId: entry.defaultActorId, actorKind: "agent", + userId: normalize(entry.ownerUserId), }; } - - if (jwtAssertion) { - const parts = jwtAssertion.split("."); - if (parts.length === 3 && parts[1]) { - try { - const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64.padEnd( - base64.length + ((4 - (base64.length % 4)) % 4), - "=", + const cookie = headers + .get("cookie") + ?.match(/(?:^|;\s*)CF_Authorization=([^;]+)/)?.[1]; + const token = headers.get("cf-access-jwt-assertion")?.trim() || cookie; + const access = config?.access; + if (!token || !access?.issuer || !access.audience) return null; + try { + let keys; + if (access.jwks) { + keys = localKeys.get(access.jwks); + if (!keys) { + keys = createLocalJWKSet(access.jwks); + localKeys.set(access.jwks, keys); + } + } else { + keys = remoteKeys.get(access.issuer); + if (!keys) { + keys = createRemoteJWKSet( + new URL("/cdn-cgi/access/certs", access.issuer), ); - const payload = JSON.parse(atob(padded)) as { - email?: string; - common_name?: string; - }; - if (typeof payload.email === "string" && payload.email.trim()) { - return { - actorId: payload.email.trim(), - actorKind: "human", - }; - } - if ( - typeof payload.common_name === "string" && - payload.common_name.trim() - ) { - const explicitAgentId = headers.get("x-agent-id")?.trim(); - return { - actorId: explicitAgentId || `agent:${payload.common_name.trim()}`, - actorKind: "agent", - }; - } - } catch { - // Ignore malformed JWT payload and fall through + remoteKeys.set(access.issuer, keys); } } + const { payload } = await jwtVerify(token, keys, { + issuer: access.issuer, + audience: access.audience, + algorithms: ["RS256"], + requiredClaims: ["exp", "iss", "aud"], + }); + if (typeof payload.email === "string" && payload.email.trim()) { + const id = normalize(payload.email); + return { actorId: id, actorKind: "human", userId: id }; + } + if (typeof payload.common_name === "string") { + const entry = config?.allowedServiceTokens?.[payload.common_name]; + if (entry?.ownerUserId?.trim() && entry.defaultActorId?.trim()) + return { + actorId: entry.defaultActorId, + actorKind: "agent", + userId: normalize(entry.ownerUserId), + }; + } + } catch { + /* fail closed: never fall back to an unverified email or decoded JWT */ } - return null; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 635b5e9..5b353f2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,11 +8,7 @@ import { } from "@video-compose/domain"; import { createApiApp } from "./app.ts"; import type { AccessAuthConfig } from "./auth.ts"; -import { - D1OAuthStore, - InMemoryOAuthStore, - type OAuthStore, -} from "./oauth.ts"; +import { D1OAuthStore, InMemoryOAuthStore, type OAuthStore } from "./oauth.ts"; export * from "./auth.ts"; export * from "./mcp.ts"; @@ -26,6 +22,9 @@ export interface WorkerEnv { fetch(request: Request): Promise; }; ALLOWED_SERVICE_TOKENS_JSON?: string; + ACCESS_TEAM_DOMAIN?: string; + ACCESS_AUD?: string; + ACCESS_JWKS_JSON?: string; } const fallbackMemoryService = new VideoComposeDomainService(); @@ -38,6 +37,7 @@ export function createWorkerHandler( const url = new URL(request.url); const isWorkerRoute = url.pathname === "/mcp" || + url.pathname === "/logout" || url.pathname === "/v1" || url.pathname.startsWith("/v1/") || url.pathname === "/oauth" || @@ -59,16 +59,25 @@ export function createWorkerHandler( oauthStore = new D1OAuthStore(env.DB); } - let authConfig: AccessAuthConfig | undefined; + let authConfig: AccessAuthConfig = {}; + if (env?.ACCESS_TEAM_DOMAIN && env.ACCESS_AUD) + authConfig.access = { + issuer: `https://${env.ACCESS_TEAM_DOMAIN}`, + audience: env.ACCESS_AUD, + ...(env.ACCESS_JWKS_JSON + ? { jwks: JSON.parse(env.ACCESS_JWKS_JSON) } + : {}), + }; if (env?.ALLOWED_SERVICE_TOKENS_JSON) { try { authConfig = { + ...authConfig, allowedServiceTokens: JSON.parse( env.ALLOWED_SERVICE_TOKENS_JSON, ) as AccessAuthConfig["allowedServiceTokens"], }; } catch { - authConfig = undefined; + authConfig.allowedServiceTokens = {}; } } diff --git a/apps/api/src/mcp-http.ts b/apps/api/src/mcp-http.ts new file mode 100644 index 0000000..df81459 --- /dev/null +++ b/apps/api/src/mcp-http.ts @@ -0,0 +1,196 @@ +import type { ActorIdentity } from "@video-compose/protocol"; +import type { VideoComposeDomainService } from "@video-compose/domain"; +import { + handleMcpJsonRpc, + LEGACY_MCP_VERSIONS, + MODERN_MCP_VERSION, + type JsonRpcRequest, +} from "./mcp.ts"; + +const object = (v: unknown): v is Record => + !!v && typeof v === "object" && !Array.isArray(v); +const decodeHeader = (value: string | null) => { + if (!value?.startsWith("=?base64?") || !value.endsWith("?=")) return value; + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + Uint8Array.from(atob(value.slice(9, -2)), (c) => c.charCodeAt(0)), + ); + } catch { + return null; + } +}; +/** Streamable HTTP framing is shared by OAuth and Access-authenticated callers. */ +export async function mcpHttp( + request: Request, + domain: VideoComposeDomainService, + actor: ActorIdentity | null, + cors: Record, +): Promise { + const origin = new URL(request.url).origin; + const headers = { + ...cors, + "Cache-Control": "private, no-store", + Vary: "Authorization, Cookie, Origin", + }; + const reply = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { ...headers, "Content-Type": "application/json" }, + }); + const error = ( + code: number, + message: string, + status: number, + id: unknown = null, + data?: unknown, + ) => + reply( + { + jsonrpc: "2.0", + id, + error: { code, message, ...(data ? { data } : {}) }, + }, + status, + ); + if (!actor) + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { + code: -32001, + message: "Authentication is required.", + data: { code: "UNAUTHORIZED" }, + }, + }), + { + status: 401, + headers: { + ...headers, + "Content-Type": "application/json", + "WWW-Authenticate": `Bearer realm="video-compose", resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, + }, + }, + ); + const callerOrigin = request.headers.get("origin"); + if ( + callerOrigin && + ![origin, "https://chatgpt.com", "https://chat.openai.com"].includes( + callerOrigin, + ) + ) + return error(-32000, "Origin is not allowed.", 403); + let message: unknown; + try { + message = await request.json(); + } catch { + return error(-32700, "Parse error", 400); + } + if ( + !object(message) || + message.jsonrpc !== "2.0" || + ("id" in message && + message.id !== null && + typeof message.id !== "string" && + typeof message.id !== "number") || + ("params" in message && !object(message.params)) + ) + return error(-32600, "Invalid Request", 400); + const id = message.id ?? null; + const params = message.params as Record | undefined; + const meta = params?._meta; + if (meta !== undefined && !object(meta)) + return error(-32600, "Invalid request metadata", 400, id); + const bodyVersion = object(meta) + ? meta["io.modelcontextprotocol/protocolVersion"] + : undefined; + const headerVersion = request.headers.get("MCP-Protocol-Version"); + if ( + bodyVersion !== undefined && + headerVersion !== null && + headerVersion !== bodyVersion + ) + return error( + -32020, + "HeaderMismatch: protocol version must match the body.", + 400, + id, + ); + const version = headerVersion ?? bodyVersion; + const modern = version === MODERN_MCP_VERSION; + if ( + version !== undefined && + version !== null && + version !== MODERN_MCP_VERSION && + !LEGACY_MCP_VERSIONS.includes(String(version)) && + message.method !== "initialize" + ) + return error(-32022, "Unsupported protocol version", 400, id, { + supported: [MODERN_MCP_VERSION, ...LEGACY_MCP_VERSIONS], + requested: version, + }); + if (typeof message.method !== "string") { + // Legacy client replies are accepted but this stateless server has no pending server requests. + if ( + !modern && + "id" in message && + "result" in message !== "error" in message + ) + return new Response(null, { status: 202, headers }); + return error(-32600, "Invalid Request", 400, id); + } + if (!("id" in message)) { + if (!message.method.startsWith("notifications/")) + return error(-32600, "Only notifications may omit an id.", 400); + return new Response(null, { status: 202, headers }); + } + if (modern) { + if ( + headerVersion !== bodyVersion || + request.headers.get("Mcp-Method") !== message.method + ) + return error( + -32020, + "HeaderMismatch: protocol version and method must match the body.", + 400, + id, + ); + if ( + !object(meta) || + !object(meta["io.modelcontextprotocol/clientInfo"]) || + !object(meta["io.modelcontextprotocol/clientCapabilities"]) + ) + return error( + -32602, + "Modern requests require clientInfo and clientCapabilities metadata.", + 400, + id, + ); + if ( + ["tools/call", "resources/read", "prompts/get"].includes( + message.method, + ) && + decodeHeader(request.headers.get("Mcp-Name")) !== + (params?.name ?? params?.uri) + ) + return error( + -32020, + "HeaderMismatch: Mcp-Name must match the body.", + 400, + id, + ); + } + const response = await handleMcpJsonRpc( + domain, + actor, + message as unknown as JsonRpcRequest, + ); + return reply( + response, + modern && response.error?.code === -32601 + ? 404 + : response.error?.code === -32022 + ? 400 + : 200, + ); +} diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts index e1189ef..445acc6 100644 --- a/apps/api/src/mcp.ts +++ b/apps/api/src/mcp.ts @@ -1,4 +1,9 @@ -import { AUTHORING_TOOLS, AUTHORING_SERVER_INSTRUCTIONS, WORKFLOW_CONTEXT_SCHEMA, authoringRpc } from "./authoring-mcp.ts"; +import { + AUTHORING_TOOLS, + AUTHORING_SERVER_INSTRUCTIONS, + WORKFLOW_CONTEXT_SCHEMA, + authoringRpc, +} from "./authoring-mcp.ts"; import type { WorkflowContext } from "@video-compose/protocol"; import { DomainError, @@ -44,6 +49,69 @@ export interface McpToolDefinition { } export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ + { + name: "trash_list", + description: + "List only recycle-bin items owned by or explicitly shared with this authenticated account.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "resource_trash", + description: + "Move an owned project or writable composition into the recycle bin. Preserve history/media. Requires exact lifecycle and composition revisions.", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string" }, + kind: { enum: ["project", "composition"] }, + id: { type: "string" }, + baseRevision: { type: "integer", minimum: 0 }, + operationId: { type: "string" }, + expectedHeadRevision: { type: "integer", minimum: 1 }, + }, + required: ["projectId", "kind", "id", "baseRevision", "operationId"], + }, + }, + { + name: "resource_restore", + description: + "Restore a recycle-bin item with exact lifecycle revision; restore the parent project first. Does not restore separately trashed compositions.", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string" }, + kind: { enum: ["project", "composition"] }, + id: { type: "string" }, + baseRevision: { type: "integer", minimum: 0 }, + operationId: { type: "string" }, + expectedHeadRevision: { type: "integer", minimum: 1 }, + }, + required: ["projectId", "kind", "id", "baseRevision", "operationId"], + }, + }, + { + name: "project_member_set", + description: + "Owner-only explicit sharing or revocation with reader/writer permissions. Account identity and ownership are server-derived.", + inputSchema: { + type: "object", + properties: { + projectId: { type: "string" }, + targetActorId: { type: "string" }, + role: { enum: ["reader", "writer", null] }, + baseRevision: { type: "integer", minimum: 0 }, + operationId: { type: "string" }, + }, + required: [ + "projectId", + "targetActorId", + "role", + "baseRevision", + "operationId", + ], + }, + }, + ...AUTHORING_TOOLS, { name: "project_list", @@ -66,7 +134,8 @@ export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ }, { name: "project_overview", - description: "Get authorized project overview, composition list and shared resource counts", + description: + "Get authorized project overview, composition list and shared resource counts", inputSchema: { type: "object", properties: { @@ -191,12 +260,7 @@ export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ }, operationId: { type: "string", description: "Idempotency key" }, }, - required: [ - "projectId", - "compositionId", - "targetRevision", - "operationId", - ], + required: ["projectId", "compositionId", "targetRevision", "operationId"], }, }, { @@ -286,7 +350,8 @@ export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ }, { name: "asset_link", - description: "Attach a candidate or selected asset to a shot (maximum 5 candidate images per shot)", + description: + "Attach a candidate or selected asset to a shot (maximum 5 candidate images per shot)", inputSchema: { type: "object", properties: { @@ -344,7 +409,8 @@ export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ }, { name: "asset_update_candidate", - description: "Update metadata (role, notes, actor) of a shot's candidate asset", + description: + "Update metadata (role, notes, actor) of a shot's candidate asset", inputSchema: { type: "object", properties: { @@ -360,7 +426,10 @@ export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ description: "Media kind (default: images)", }, role: { type: "string", description: "Role tag" }, - actor: { type: "string", description: "Actor or contributor identifier" }, + actor: { + type: "string", + description: "Actor or contributor identifier", + }, notes: { type: "string", description: "Notes on candidate" }, }, required: [ @@ -521,7 +590,8 @@ export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ }, { name: "shot_subject_bind", - description: "Pin a shot to a specific approved subject revision and variant", + description: + "Pin a shot to a specific approved subject revision and variant", inputSchema: { type: "object", properties: { @@ -604,13 +674,51 @@ export const MCP_TOOLS_MANIFEST: McpToolDefinition[] = [ ]; for (const tool of MCP_TOOLS_MANIFEST) { - if (["composition_create", "composition_patch", "composition_import", "shot_subject_bind", "subject_create", "subject_patch", "subject_reference_attach", "composition_restore", "subject_approve_revision", "asset_link", "asset_unlink", "asset_update_candidate", "review_approve"].includes(tool.name)) { + if ( + [ + "composition_create", + "composition_patch", + "composition_import", + "shot_subject_bind", + "subject_create", + "subject_patch", + "subject_reference_attach", + "composition_restore", + "subject_approve_revision", + "asset_link", + "asset_unlink", + "asset_update_candidate", + "review_approve", + ].includes(tool.name) + ) { tool.inputSchema.properties.workflowContext = WORKFLOW_CONTEXT_SCHEMA; - tool.description += " In strict projects, read authoring_workflow_get first and provide workflowContext. Raw mixed boards cannot become canonical references."; + tool.description += + " In strict projects, read authoring_workflow_get first and provide workflowContext. Raw mixed boards cannot become canonical references."; } - if (tool.name === "composition_import") tool.inputSchema.properties.baseRevision = { type: "integer", minimum: 0, description: "Required when importing into an existing strict composition." }; - if (tool.name === "composition_export") tool.inputSchema.properties.targetVersion = { enum: ["video-compose/v1", "video-compose/v2"], description: "v2 material dependencies cannot be dropped for v1 readers." }; - if (tool.name === "project_create") tool.inputSchema.properties.authoringMode = { enum: ["strict", "legacy"], description: "New projects default to strict. legacy is an explicit archive/compatibility mode." }; + if (tool.name === "composition_import") + tool.inputSchema.properties.baseRevision = { + type: "integer", + minimum: 0, + description: + "Required when importing into an existing strict composition.", + }; + if (tool.name === "composition_export") + tool.inputSchema.properties.targetVersion = { + enum: ["video-compose/v1", "video-compose/v2"], + description: "v2 material dependencies cannot be dropped for v1 readers.", + }; + if (tool.name === "project_create") + tool.inputSchema.properties.sharingMode = { + enum: ["private", "members"], + description: + "Private by default. Explicitly granted human members enable sharing; only owner can change membership.", + }; + if (tool.name === "project_create") + tool.inputSchema.properties.authoringMode = { + enum: ["strict", "legacy"], + description: + "New projects default to strict. legacy is an explicit archive/compatibility mode.", + }; } function decodeBase64Bytes(base64: string): Uint8Array { @@ -622,10 +730,92 @@ function decodeBase64Bytes(base64: string): Uint8Array { return bytes; } +export const MODERN_MCP_VERSION = "2026-07-28"; +export const LEGACY_MCP_VERSIONS = ["2025-03-26", "2025-06-18", "2025-11-25"]; + +/** Adapt wire results without changing shared domain operations or legacy payloads. */ export async function handleMcpJsonRpc( domain: VideoComposeDomainService, actor: ActorIdentity | null, rpc: JsonRpcRequest, +): Promise { + const meta = rpc.params?._meta as Record | undefined; + const version = meta?.["io.modelcontextprotocol/protocolVersion"]; + if (!actor) return handleLegacyMcpJsonRpc(domain, actor, rpc); + if ( + version !== undefined && + version !== MODERN_MCP_VERSION && + !LEGACY_MCP_VERSIONS.includes(String(version)) + ) + return { + jsonrpc: "2.0", + id: rpc.id ?? null, + error: { + code: -32022, + message: "Unsupported protocol version", + data: { supported: [MODERN_MCP_VERSION], requested: version }, + }, + }; + if (version === MODERN_MCP_VERSION) { + if (rpc.method === "server/discover") { + const legacy = await handleLegacyMcpJsonRpc(domain, actor, { + jsonrpc: "2.0", + id: rpc.id, + method: "initialize", + params: { protocolVersion: "2025-11-25" }, + }); + const info = legacy.result as { + instructions: string; + serverInfo: unknown; + }; + return { + jsonrpc: "2.0", + id: rpc.id ?? null, + result: { + resultType: "complete", + supportedVersions: [MODERN_MCP_VERSION], + capabilities: { tools: {}, prompts: {}, resources: {} }, + _meta: { "io.modelcontextprotocol/serverInfo": info.serverInfo }, + instructions: info.instructions, + ttlMs: 0, + cacheScope: "private", + }, + }; + } + if (rpc.method === "initialize") + return { + jsonrpc: "2.0", + id: rpc.id ?? null, + error: { + code: -32601, + message: + "Modern requests use server/discover; legacy initialize remains available with a legacy version.", + }, + }; + const response = await handleLegacyMcpJsonRpc(domain, actor, rpc); + if (response.result) + response.result = { + ...(response.result as Record), + resultType: "complete", + ...([ + "tools/list", + "prompts/list", + "resources/list", + "resources/templates/list", + "resources/read", + ].includes(rpc.method) + ? { ttlMs: 0, cacheScope: "private" } + : {}), + }; + return response; + } + return handleLegacyMcpJsonRpc(domain, actor, rpc); +} + +async function handleLegacyMcpJsonRpc( + domain: VideoComposeDomainService, + actor: ActorIdentity | null, + rpc: JsonRpcRequest, ): Promise { const id = rpc.id ?? null; @@ -635,7 +825,8 @@ export async function handleMcpJsonRpc( id, error: { code: -32001, - message: "UNAUTHORIZED: Missing or invalid Cloudflare Access credentials.", + message: + "UNAUTHORIZED: Missing or invalid Cloudflare Access credentials.", data: { code: "UNAUTHORIZED" }, }, }; @@ -651,8 +842,14 @@ export async function handleMcpJsonRpc( jsonrpc: "2.0", id, result: { - protocolVersion: ["2025-03-26", "2025-06-18", "2025-11-25"].includes(requestedVersion) ? requestedVersion : "2025-11-25", - instructions: AUTHORING_SERVER_INSTRUCTIONS, + protocolVersion: ["2025-03-26", "2025-06-18", "2025-11-25"].includes( + requestedVersion, + ) + ? requestedVersion + : "2025-11-25", + instructions: + AUTHORING_SERVER_INSTRUCTIONS + + "\nAccounts and recycle bin: New projects are private to the authenticated account. Only the owner may explicitly grant/revoke project members or trash/restore a project. Reader/writer sharing includes nested assets and subjects; writers may trash/restore compositions. Never select an identity via headers or claim another account. Before trash/restore, read current lifecycleRevision and composition headRevision; submit baseRevision, expectedHeadRevision for compositions, and a stable operationId. Use trash_list for deleted items; restore the parent project before child compositions. Restoring a project does not restore separately deleted compositions. composition_restore restores content history, resource_restore restores recycle-bin state.", serverInfo: { name: "video-compose-mcp", version: "0.1.0", @@ -683,9 +880,32 @@ export async function handleMcpJsonRpc( }; } - if (["prompts/list", "prompts/get", "resources/list", "resources/templates/list", "resources/read"].includes(rpc.method)) { - try { return { jsonrpc: "2.0", id, result: await authoringRpc(domain, actor, rpc) }; } - catch (err) { if (err instanceof DomainError) throw err; return { jsonrpc: "2.0", id, error: { code: -32602, message: err instanceof Error ? err.message : String(err) } }; } + if ( + [ + "prompts/list", + "prompts/get", + "resources/list", + "resources/templates/list", + "resources/read", + ].includes(rpc.method) + ) { + try { + return { + jsonrpc: "2.0", + id, + result: await authoringRpc(domain, actor, rpc), + }; + } catch (err) { + if (err instanceof DomainError) throw err; + return { + jsonrpc: "2.0", + id, + error: { + code: -32602, + message: err instanceof Error ? err.message : String(err), + }, + }; + } } if (rpc.method === "tools/list") { @@ -758,17 +978,72 @@ async function dispatchMcpTool( ): Promise { const projectId = String(args.projectId ?? ""); switch (toolName) { - case "authoring_dependencies_get": return domain.authoring.impact(actor, projectId, { id: String(args.id ?? ""), revision: Number(args.revision) }); - case "authoring_workflow_get": return domain.authoring.workflow(actor, projectId, args.language === "en" ? "en" : "zh"); - case "authoring_document_list": return domain.authoring.list(actor, projectId); - case "authoring_document_get": return domain.authoring.get(actor, projectId, String(args.id ?? ""), args.revision === undefined ? undefined : Number(args.revision)); - case "reference_plan_validate": return domain.authoring.validate(actor, projectId, args.document); - case "authoring_document_put": return domain.authoring.put(actor, args as unknown as Parameters[1]); - case "authoring_document_review": return domain.authoring.review(actor, args as unknown as Parameters[1]); - case "authoring_document_approve": return domain.authoring.approve(actor, args as unknown as Parameters[1]); - case "generation_inputs_prepare": return domain.authoring.prepare(actor, projectId, args.request, args.workflowContext as WorkflowContext | undefined); - case "generation_evidence_record": return domain.authoring.recordEvidence(actor, args as unknown as Parameters[1]); - case "generation_evidence_list": return domain.authoring.evidence(actor, projectId); + case "trash_list": + return domain.lifecycle.list(actor); + case "resource_trash": + case "resource_restore": + return domain.lifecycle.change(actor, { + ...args, + action: toolName === "resource_trash" ? "trash" : "restore", + } as Parameters[1]); + case "project_member_set": + return domain.setProjectMemberRole( + actor, + args as Parameters[1], + ); + case "authoring_dependencies_get": + return domain.authoring.impact(actor, projectId, { + id: String(args.id ?? ""), + revision: Number(args.revision), + }); + case "authoring_workflow_get": + return domain.authoring.workflow( + actor, + projectId, + args.language === "en" ? "en" : "zh", + ); + case "authoring_document_list": + return domain.authoring.list(actor, projectId); + case "authoring_document_get": + return domain.authoring.get( + actor, + projectId, + String(args.id ?? ""), + args.revision === undefined ? undefined : Number(args.revision), + ); + case "reference_plan_validate": + return domain.authoring.validate(actor, projectId, args.document); + case "authoring_document_put": + return domain.authoring.put( + actor, + args as unknown as Parameters[1], + ); + case "authoring_document_review": + return domain.authoring.review( + actor, + args as unknown as Parameters[1], + ); + case "authoring_document_approve": + return domain.authoring.approve( + actor, + args as unknown as Parameters[1], + ); + case "generation_inputs_prepare": + return domain.authoring.prepare( + actor, + projectId, + args.request, + args.workflowContext as WorkflowContext | undefined, + ); + case "generation_evidence_record": + return domain.authoring.recordEvidence( + actor, + args as unknown as Parameters< + typeof domain.authoring.recordEvidence + >[1], + ); + case "generation_evidence_list": + return domain.authoring.evidence(actor, projectId); } switch (toolName) { @@ -786,8 +1061,10 @@ async function dispatchMcpTool( id: String(args.id ?? ""), name: String(args.name ?? ""), authoringMode: args.authoringMode as "strict" | "legacy" | undefined, + sharingMode: args.sharingMode as "private" | "members" | undefined, description: args.description ? String(args.description) : undefined, - members: args.members as Record | undefined, + members: args.members as + Record | undefined, operationId: args.operationId ? String(args.operationId) : undefined, }); @@ -839,7 +1116,10 @@ async function dispatchMcpTool( case "composition_export": return domain.exportCompositionBundle(actor, { - targetVersion: args.targetVersion === "video-compose/v1" ? "video-compose/v1" : undefined, + targetVersion: + args.targetVersion === "video-compose/v1" + ? "video-compose/v1" + : undefined, projectId: String(args.projectId ?? ""), compositionId: String(args.compositionId ?? ""), revision: typeof args.revision === "number" ? args.revision : undefined, @@ -848,7 +1128,8 @@ async function dispatchMcpTool( case "composition_import": return domain.importCompositionBundle(actor, { - baseRevision: typeof args.baseRevision === "number" ? args.baseRevision : undefined, + baseRevision: + typeof args.baseRevision === "number" ? args.baseRevision : undefined, workflowContext: args.workflowContext as WorkflowContext | undefined, projectId: String(args.projectId ?? ""), payload: String(args.payload ?? ""), @@ -879,7 +1160,9 @@ async function dispatchMcpTool( width: typeof args.width === "number" ? args.width : undefined, height: typeof args.height === "number" ? args.height : undefined, durationFrames: - typeof args.durationFrames === "number" ? args.durationFrames : undefined, + typeof args.durationFrames === "number" + ? args.durationFrames + : undefined, }); } @@ -894,7 +1177,8 @@ async function dispatchMcpTool( { op: "attach_shot_candidate", shotId: String(args.shotId ?? ""), - mediaKind: (args.mediaKind ?? "images") as "images" | "videos" | "audios", + mediaKind: (args.mediaKind ?? "images") as + "images" | "videos" | "audios", candidate: { asset: String(args.assetId ?? ""), actor: actor.actorId, @@ -916,7 +1200,8 @@ async function dispatchMcpTool( { op: "detach_shot_candidate", shotId: String(args.shotId ?? ""), - mediaKind: (args.mediaKind ?? "images") as "images" | "videos" | "audios", + mediaKind: (args.mediaKind ?? "images") as + "images" | "videos" | "audios", assetId: String(args.assetId ?? ""), }, ], @@ -933,12 +1218,17 @@ async function dispatchMcpTool( { op: "update_shot_candidate", shotId: String(args.shotId ?? ""), - mediaKind: (args.mediaKind ?? "images") as "images" | "videos" | "audios", + mediaKind: (args.mediaKind ?? "images") as + "images" | "videos" | "audios", assetId: String(args.assetId ?? ""), candidate: { ...(args.role !== undefined ? { role: String(args.role) } : {}), - ...(args.actor !== undefined ? { actor: String(args.actor) } : {}), - ...(args.notes !== undefined ? { notes: String(args.notes) } : {}), + ...(args.actor !== undefined + ? { actor: String(args.actor) } + : {}), + ...(args.notes !== undefined + ? { notes: String(args.notes) } + : {}), }, }, ], @@ -976,8 +1266,13 @@ async function dispatchMcpTool( defaultVariant: args.defaultVariant ? String(args.defaultVariant) : undefined, - status: args.status ? (String(args.status) as RevisionStatus) : "candidate", - definition: args.definition as { description: string; invariants: string[] }, + status: args.status + ? (String(args.status) as RevisionStatus) + : "candidate", + definition: args.definition as { + description: string; + invariants: string[]; + }, variants: args.variants as Record, }); diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index f9388ec..a6ba227 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -4,35 +4,44 @@ "main": "src/index.ts", "compatibility_date": "2026-10-01", "workers_dev": false, + "vars": { + "ACCESS_TEAM_DOMAIN": "toughstruct.cloudflareaccess.com", + "ACCESS_AUD": "87564c49249682da499869e50295186b3488c730ec5bd236105a2db4598453ec", + }, "routes": [ { "pattern": "vflow.talkincode.net", - "custom_domain": true + "custom_domain": true, }, { "pattern": "video-compose.talkincode.net", - "custom_domain": true - } + "custom_domain": true, + }, ], "assets": { "directory": "../web/dist", "binding": "ASSETS", "not_found_handling": "single-page-application", - "run_worker_first": ["/v1/*", "/mcp", "/oauth/*", "/.well-known/*"] + "run_worker_first": [ + "/v1/*", + "/mcp", + "/oauth/*", + "/.well-known/*", + "/logout", + ], }, "d1_databases": [ { "binding": "DB", "database_name": "video-compose-db", "database_id": "028d0760-9c64-4f4d-876e-8e6b33236acc", - "migrations_dir": "migrations" - } + "migrations_dir": "migrations", + }, ], "r2_buckets": [ { "binding": "MEDIA_BUCKET", - "bucket_name": "video-compose-media" - } - ] + "bucket_name": "video-compose-media", + }, + ], } - diff --git a/apps/web/src/AccountMenu.tsx b/apps/web/src/AccountMenu.tsx new file mode 100644 index 0000000..5d2d789 --- /dev/null +++ b/apps/web/src/AccountMenu.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from "react"; +import type { VideoComposeClient } from "@video-compose/client"; +export function AccountMenu({ + client, +}: { + client?: VideoComposeClient; +}): React.ReactElement { + const [account, setAccount] = useState(""); + useEffect(() => { + let live = true; + if (client) + void client + .getSession() + .then((s) => { + if (live) setAccount(s.actor.userId ?? s.actor.actorId); + }) + .catch(() => { + if (live) setAccount("登录已失效"); + }); + return () => { + live = false; + }; + }, [client]); + return ( + + ); +} +export function SessionBoundary({ + client, + children, +}: { + client: VideoComposeClient; + children: React.ReactNode; +}): React.ReactElement { + const [account, setAccount] = useState(null); + const [failed, setFailed] = useState(false); + useEffect(() => { + let live = true; + let generation = 0; + const check = () => { + const current = ++generation; + setAccount(null); + setFailed(false); + void client + .getSession() + .then((s) => { + if (live && current === generation) + setAccount(s.actor.userId ?? s.actor.actorId); + }) + .catch(() => { + if (live && current === generation) setFailed(true); + }); + }; + const show = (e: PageTransitionEvent) => { + if (e.persisted) check(); + }; + check(); + window.addEventListener("pageshow", show); + return () => { + live = false; + window.removeEventListener("pageshow", show); + }; + }, [client]); + if (failed) + return ( +
+

登录已失效

+

请重新登录后访问你的项目。

+ + 重新登录 + +
+ ); + if (!account) + return ( +
+

正在核验账号…

+
+ ); + return {children}; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9d3f04b..3137bbe 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,9 +6,11 @@ import { CompositionWorkspace, PageError, } from "./Projects.tsx"; +import { SessionBoundary } from "./AccountMenu.tsx"; +import { TrashPage } from "./TrashPage.tsx"; import { readRoute } from "./routes.ts"; -export function App({ +function AppRoutes({ client, pathname = window.location.pathname, }: { @@ -16,6 +18,7 @@ export function App({ pathname?: string; }): React.ReactElement { const route = readRoute(pathname); + if (route.kind === "trash") return ; if (route.kind === "projects") return ; if (route.kind === "project") return ( @@ -36,3 +39,14 @@ export function App({ ); return ; } + +export function App(props: { + client: VideoComposeClient; + pathname?: string; +}): React.ReactElement { + return ( + + + + ); +} diff --git a/apps/web/src/Projects.tsx b/apps/web/src/Projects.tsx index 973fbe1..8b7c73b 100644 --- a/apps/web/src/Projects.tsx +++ b/apps/web/src/Projects.tsx @@ -2,6 +2,12 @@ import type { VideoComposeClient } from "@video-compose/client"; import { DomainError } from "@video-compose/domain"; import { PROTOCOL_VERSION } from "@video-compose/protocol"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { AccountMenu } from "./AccountMenu.tsx"; +import { + DeleteDialog, + SharingDialog, + type DeleteTarget, +} from "./ResourceControls.tsx"; import { Workbench } from "./Workbench.tsx"; import { compositionUrl, projectUrl } from "./routes.ts"; @@ -57,8 +63,10 @@ function useRemote(read: () => Promise) { function PageHeader({ children, + client, }: { children?: React.ReactNode; + client?: VideoComposeClient; }): React.ReactElement { return (
@@ -69,6 +77,7 @@ function PageHeader({ +
); } @@ -144,7 +153,7 @@ export function ProjectsPage({ ); return (
- +
@@ -255,6 +264,8 @@ export function ProjectPage({ ); const { data, error, reload } = useRemote(read); const [creating, setCreating] = useState(false); + const [deleting, setDeleting] = useState(null); + const [sharing, setSharing] = useState(false); const [query, setQuery] = useState(""); if (error) return ; if (!data) return ; @@ -266,7 +277,7 @@ export function ProjectPage({ const writer = data.role === "writer"; return (
- + 全部项目 {data.project.name} @@ -303,6 +314,25 @@ export function ProjectPage({
{Object.keys(data.project.members).length}
+ {data.isOwner && ( +
+ + +
+ )}

项目编排

@@ -346,26 +376,44 @@ export function ProjectPage({ ) : (
{visible.map((composition, index) => ( - -
- - {String(index + 1).padStart(2, "0")} - - 修订 #{composition.headRevision} - -
-
-

{composition.title || composition.id}

- {composition.id} - 进入工作台 → -
-
+ ))}
)} @@ -373,6 +421,28 @@ export function ProjectPage({ 素材和角色设定由此项目的编排共享,每条编排分别保留自己的分镜与修订。

+ {deleting && ( + setDeleting(null)} + onComplete={() => { + const projectDeleted = deleting.kind === "project"; + setDeleting(null); + if (projectDeleted) navigate("/trash"); + else reload(); + }} + /> + )} + {sharing && ( + setSharing(false)} + onChanged={reload} + /> + )} void, busy: boolean) { + const ref = useRef(null); + useEffect(() => { + const previous = document.activeElement; + const overflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + ref.current?.querySelector("button")?.focus(); + return () => { + document.body.style.overflow = overflow; + if (previous instanceof HTMLElement && previous.isConnected) + previous.focus(); + }; + }, []); + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape" && !busy) { + event.preventDefault(); + close(); + } + if (event.key === "Tab") { + const elements = Array.from( + ref.current?.querySelectorAll( + "button:not(:disabled),input:not(:disabled),select:not(:disabled),a[href]", + ) ?? [], + ); + const first = elements[0], + last = elements.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + }; + return { ref, onKeyDown }; +} + +export interface DeleteTarget { + projectId: string; + kind: "project" | "composition"; + id: string; + title: string; + baseRevision: number; + expectedHeadRevision?: number; +} +const failure = (e: unknown) => (e instanceof Error ? e.message : String(e)); +export function DeleteDialog({ + client, + target, + onCancel, + onComplete, +}: { + client: VideoComposeClient; + target: DeleteTarget; + onCancel: () => void; + onComplete: () => void; +}): React.ReactElement { + const [pending, setPending] = useState(false), + [error, setError] = useState(""); + const operation = useRef(crypto.randomUUID()); + const lock = useRef(false); + const dialog = useDialog(onCancel, pending); + return ( +
+
+

移入回收站

+

+ 确认删除{target.kind === "project" ? "项目" : "编排"}「{target.title} + 」? +

+

+ {target.kind === "project" + ? "该项目的编排、素材和角色会暂时不可访问。恢复项目后内容仍在;之前单独删除的编排仍保留在回收站。" + : "分镜、历史和选定输出均保留,可以从回收站恢复。"}{" "} + 此操作会影响有权访问它的共享成员。 +

+ {error && ( +

{error} 确认信息已保留,请核对最新状态后重试。

+ )} +
+ + +
+
+
+ ); +} +export function SharingDialog({ + client, + project, + onClose, + onChanged, +}: { + client: VideoComposeClient; + project: ProjectRecord; + onClose: () => void; + onChanged: () => void; +}): React.ReactElement { + const [email, setEmail] = useState(""), + [role, setRole] = useState<"reader" | "writer">("reader"), + [error, setError] = useState(""), + [busy, setBusy] = useState(false); + const lock = useRef(false); + const retries = useRef(new Map()); + const update = ( + targetActorId: string, + nextRole: "reader" | "writer" | null, + ) => { + if (lock.current) return; + lock.current = true; + setBusy(true); + setError(""); + const key = JSON.stringify([ + project.membershipRevision ?? 0, + targetActorId, + nextRole, + ]); + const operationId = retries.current.get(key) ?? crypto.randomUUID(); + retries.current.set(key, operationId); + void client + .setProjectMember({ + projectId: project.id, + targetActorId, + role: nextRole, + baseRevision: project.membershipRevision ?? 0, + operationId, + }) + .then(() => { + retries.current.delete(key); + setEmail(""); + onChanged(); + }) + .catch((e) => setError(failure(e))) + .finally(() => { + lock.current = false; + setBusy(false); + }); + }; + const dialog = useDialog(onClose, busy); + return ( +
+
+

共享设置

+

+ 默认仅所有者可见。明确授权后,成员可访问本项目及其中的编排、素材和角色。只有所有者可以调整共享权限。 +

+

所有者:{project.ownerUserId ?? project.createdBy}

+ {error && ( +

+ {error} 输入已保留;如权限版本变化,请关闭并刷新后核对。 +

+ )} +
    + {Object.entries(project.members) + .filter(([id]) => id !== (project.ownerUserId ?? project.createdBy)) + .map(([id, current]) => ( +
  • + {id} + + +
  • + ))} +
+
{ + e.preventDefault(); + update(email.trim().toLowerCase(), role); + }} + > + + + +
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/TrashPage.tsx b/apps/web/src/TrashPage.tsx new file mode 100644 index 0000000..8e93b93 --- /dev/null +++ b/apps/web/src/TrashPage.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import type { VideoComposeClient } from "@video-compose/client"; +import type { TrashItem } from "@video-compose/domain"; +import { AccountMenu } from "./AccountMenu.tsx"; +export function TrashPage({ + client, +}: { + client: VideoComposeClient; +}): React.ReactElement { + const [items, setItems] = useState(null), + [error, setError] = useState(""), + [busy, setBusy] = useState(""); + const ops = useRef(new Map()); + const lock = useRef(false); + const reload = useCallback(async () => { + const result = await client.listTrash(); + setItems(result.items); + }, [client]); + useEffect(() => { + void reload().catch((e) => setError(String(e.message ?? e))); + }, [reload]); + return ( +
+
+ + Video Compose + + + +
+
+
+
+ RECYCLE BIN / 内容仍然保留 +

回收站

+

+ 恢复项目会重新开放其中的正常内容;单独删除的编排需要分别恢复。恢复父项目之前,不能恢复子编排。 +

+
+ +
+ {error &&

{error}

} + {!items ? ( +

正在加载回收站…

+ ) : items.length === 0 ? ( +
+

回收站是空的

+

删除的项目和编排会显示在这里。

+
+ ) : ( +
+ {items.map((item) => { + const key = JSON.stringify([ + item.projectId, + item.kind, + item.id, + item.lifecycleRevision, + ]); + return ( +
+
+ + {item.kind === "project" ? "项目" : "编排"} + +

{item.title}

+

+ {new Date(item.deletedAt).toLocaleString("zh-CN")} ·{" "} + {item.deletedBy} +

+ + {item.projectId} + {item.kind === "composition" ? ` / ${item.id}` : ""} + + {item.parentTrashed &&

请先恢复所属项目。

} +
+ +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/Workbench.tsx b/apps/web/src/Workbench.tsx index 54954c0..03837c3 100644 --- a/apps/web/src/Workbench.tsx +++ b/apps/web/src/Workbench.tsx @@ -12,6 +12,7 @@ import { serializeCompositionYaml, } from "@video-compose/protocol"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { AccountMenu } from "./AccountMenu.tsx"; import { MediaPreview } from "./MediaPreview.tsx"; import { AssetLibrary, @@ -20,7 +21,11 @@ import { shotPreview, } from "./WorkspacePanels.tsx"; -const AuthoringPanel = React.lazy(() => import("./AuthoringPanel.tsx").then(module => ({ default: module.AuthoringPanel }))); +const AuthoringPanel = React.lazy(() => + import("./AuthoringPanel.tsx").then((module) => ({ + default: module.AuthoringPanel, + })), +); export type WorkbenchViewMode = "grid" | "list" | "topology"; export interface WorkbenchProps { @@ -34,7 +39,8 @@ export interface WorkbenchProps { type CompositionData = Awaited< ReturnType >; -type WorkspaceTab = "shots" | "assets" | "subjects" | "authoring" | "history" | "source"; +type WorkspaceTab = + "shots" | "assets" | "subjects" | "authoring" | "history" | "source"; interface ShotDraft { description: string; duration: string; @@ -131,7 +137,9 @@ export function Workbench({ const [tab, setTab] = useState("shots"); const [authoringVisited, setAuthoringVisited] = useState(false); const [referenceDirty, setReferenceDirty] = useState(false); - useEffect(() => { if (tab === "authoring") setAuthoringVisited(true); }, [tab]); + useEffect(() => { + if (tab === "authoring") setAuthoringVisited(true); + }, [tab]); const [viewMode, setViewMode] = useState(initialView); const [selectedShotId, setSelectedShotId] = useState(null); const [query, setQuery] = useState(""); @@ -189,6 +197,14 @@ export function Workbench({ setHistory(historyResult.value.revisions); else setNotice("修订历史暂时无法加载,请重试刷新。"); } catch (err) { + if ( + err instanceof DomainError && + ["UNAUTHORIZED", "FORBIDDEN", "RESOURCE_TRASHED"].includes(err.code) + ) { + setData(null); + setSubjects([]); + setAssets([]); + } if (err instanceof DomainError && err.code === "NOT_FOUND") { if (allowBootstrap) setBootstrap(true); else setError("未找到此编排。请返回项目,选择已有编排或新建编排。"); @@ -219,6 +235,16 @@ export function Workbench({ try { await operation(); } catch (err) { + if ( + err instanceof DomainError && + ["UNAUTHORIZED", "FORBIDDEN", "RESOURCE_TRASHED"].includes(err.code) + ) { + setData(null); + setAssets([]); + setSubjects([]); + setHistory([]); + setDrafts({}); + } setError(messageOf(err)); if (err instanceof DomainError && err.code === "REVISION_CONFLICT") await refresh(); @@ -552,6 +578,7 @@ export function Workbench({ return (
@@ -587,6 +614,7 @@ export function Workbench({ > 刷新 +
{error && (
@@ -1034,7 +1062,7 @@ export function Workbench({
{Boolean( activeShot.references.images.length || - activeShot.references.videos.length, + activeShot.references.videos.length, ) && (
{[ @@ -1247,8 +1275,31 @@ export function Workbench({ patch={patch} /> -