import { randomUUID } from "crypto"; import type { ConvexHttpClient } from "convex/browser"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { apiErrorResponse } from "@/lib/api-utils"; import type { PageOptionsState } from "@/types/page-options"; export type BridgeActor = { actorType: string; actorId: string; sessionId: string | null; }; export type BridgeSource = { channel: string; client: string; }; export type BridgeTarget = { workspaceId?: string | null; pageId?: string | null; blockId?: string | null; }; export type BridgeRequestMeta = { idempotencyKey: string | null; validateOnly: boolean; dryRun: boolean; }; export type BridgeContext = { deploymentId: string | null; projectId: string | null; workspaceId: string | null; requestId: string; traceId: string; actor: BridgeActor; source: BridgeSource; tenantId: string | null; authToken: string | null; idempotencyKey: string | null; validateOnly: boolean; dryRun: boolean; }; export type BridgeErrorCode = | "VALIDATION_ERROR" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "TRANSPORT_ERROR" | "REJECTED"; export class DocumentBridgeError extends Error { constructor( message: string, public readonly status: number, public readonly code: BridgeErrorCode, public readonly details?: unknown, ) { super(message); this.name = "DocumentBridgeError"; } } export type CommandEnvelope = { name: string; commandId: string; idempotencyKey: string | null; actor: BridgeActor; source: BridgeSource; target: BridgeTarget | null; payload: T; preflightData?: Record | null; reason: string | null; refs: string[]; dryRun: boolean; validateOnly: boolean; }; export type QueryEnvelope = { name: string; payload: T; }; export type DocumentBridgeMutationRequest< TArgs extends Record = Record, > = { functionName: string; deploymentId: string | null; projectId: string | null; workspaceId: string | null; requestId: string; traceId: string; idempotencyKey: string | null; actorId: string; payloadJson: string; args: TArgs; }; export type DocumentBridgeQueryRequest< TArgs extends Record = Record, > = { functionName: string; deploymentId: string | null; projectId: string | null; workspaceId: string | null; requestId: string; traceId: string; actorId: string; payloadJson: string; args: TArgs; }; const DOCUMENT_BRIDGE_QUERY_FUNCTIONS = { "documents.content.get": "documents:getContent", "documents.meta.get": "documents:getMeta", "blocks.get": "documents:getContent", } as const satisfies Record; const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = { "documents.create": "documents:createWithParentReference", "documents.move": "documents:move", "documents.delete": "documents:softDelete", "documents.restore": "documents:restore", "documents.duplicate": "documents:duplicateWithMindmaps", "documents.copy_tree": "documents:copyTree", "documents.template": "documents:setTemplate", "documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace", "documents.purge": "documents:purge", "documents.embed": "documents:updateContent", "blocks.patch": "documents:updateContent", "blocks.move": "documents:updateContent", "blocks.embed": "documents:updateContent", "documents.title.update": "documents:updateTitle", "documents.stats.update": "documents:updateStats", "documents.options.update": "documents:updateOptions", "documents.save": "documents:updateContent", "page.head.updateTitle": "documents:updateTitle", "page.layout.updateOptions": "documents:updateOptions", "page.body.save": "documents:updateContent", "mindmaps.delete": "mindmaps:softDelete", "mindmaps.restore": "mindmaps:restore", "mindmaps.purge": "mindmaps:purge", "mindmaps.emptyTrashByWorkspace": "mindmaps:emptyTrashByWorkspace", "media.assets.replace_storage": "mediaAssets:replaceStorageFromUpload", } as const satisfies Record; function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null { for (const candidate of candidates) { const value = headerList.get(candidate); if (typeof value === "string" && value.trim()) { return value.trim(); } } return null; } function toBooleanFlag(raw: string | null): boolean { if (!raw) return false; return raw === "1" || raw.toLowerCase() === "true"; } function makeFallbackId(prefix: string): string { return `${prefix}_${randomUUID()}`; } export async function buildDocumentBridgeContext(input: { request: Request; workspaceId?: string | null; idempotencyKey?: string | null; validateOnly?: boolean; dryRun?: boolean; }): Promise { let auth; try { auth = await requireAuthContext(); } catch (error) { if (error instanceof HttpError) { throw new DocumentBridgeError(error.message || "未登录", error.status, "UNAUTHORIZED"); } throw new DocumentBridgeError("未登录", 401, "UNAUTHORIZED"); } const headerList = input.request.headers; const requestId = readHeaderValue(headerList, "x-request-id", "x-mnote-request-id") ?? makeFallbackId("req"); const traceId = readHeaderValue(headerList, "x-trace-id", "x-mnote-trace-id", "x-request-id") ?? makeFallbackId("trace"); const sessionId = readHeaderValue(headerList, "x-session-id", "x-mnote-session-id"); const sourceChannel = readHeaderValue(headerList, "x-source-channel") ?? "next-route"; const sourceClient = readHeaderValue(headerList, "x-source-client", "user-agent") ?? "wolai-frontend"; const deploymentId = readHeaderValue(headerList, "x-deployment-id") ?? process.env.VERCEL_DEPLOYMENT_ID ?? null; const projectId = readHeaderValue(headerList, "x-project-id") ?? process.env.VERCEL_PROJECT_ID ?? null; const tenantId = readHeaderValue(headerList, "x-tenant-id"); const authToken = readHeaderValue(headerList, "authorization"); const idempotencyKey = input.idempotencyKey ?? readHeaderValue(headerList, "idempotency-key", "x-idempotency-key"); const validateOnly = input.validateOnly ?? toBooleanFlag(readHeaderValue(headerList, "x-validate-only")); const dryRun = input.dryRun ?? toBooleanFlag(readHeaderValue(headerList, "x-dry-run")); return { deploymentId, projectId, workspaceId: input.workspaceId ?? null, requestId, traceId, actor: { actorType: "user", actorId: auth.userId, sessionId, }, source: { channel: sourceChannel, client: sourceClient, }, tenantId, authToken, idempotencyKey, validateOnly, dryRun, }; } export function buildDocumentBridgeContextWithActor(input: { request: Request; actor: BridgeActor; workspaceId?: string | null; idempotencyKey?: string | null; validateOnly?: boolean; dryRun?: boolean; source?: Partial; authToken?: string | null; }): BridgeContext { const headerList = input.request.headers; const requestId = readHeaderValue(headerList, "x-request-id", "x-mnote-request-id") ?? makeFallbackId("req"); const traceId = readHeaderValue(headerList, "x-trace-id", "x-mnote-trace-id", "x-request-id") ?? makeFallbackId("trace"); const sourceChannel = input.source?.channel?.trim() || readHeaderValue(headerList, "x-source-channel") || "next-route"; const sourceClient = input.source?.client?.trim() || readHeaderValue(headerList, "x-source-client", "user-agent") || "wolai-frontend"; const deploymentId = readHeaderValue(headerList, "x-deployment-id") ?? process.env.VERCEL_DEPLOYMENT_ID ?? null; const projectId = readHeaderValue(headerList, "x-project-id") ?? process.env.VERCEL_PROJECT_ID ?? null; const tenantId = readHeaderValue(headerList, "x-tenant-id"); const authToken = input.authToken ?? readHeaderValue(headerList, "authorization"); const idempotencyKey = input.idempotencyKey ?? readHeaderValue(headerList, "idempotency-key", "x-idempotency-key"); const validateOnly = input.validateOnly ?? toBooleanFlag(readHeaderValue(headerList, "x-validate-only")); const dryRun = input.dryRun ?? toBooleanFlag(readHeaderValue(headerList, "x-dry-run")); return { deploymentId, projectId, workspaceId: input.workspaceId ?? null, requestId, traceId, actor: input.actor, source: { channel: sourceChannel, client: sourceClient, }, tenantId, authToken, idempotencyKey, validateOnly, dryRun, }; } export function buildDocumentCommandEnvelope(input: { name: string; payload: T; context: BridgeContext; target?: BridgeTarget | null; preflightData?: Record | null; reason?: string | null; refs?: string[]; }): CommandEnvelope { return { name: input.name, commandId: makeFallbackId("cmd"), idempotencyKey: input.context.idempotencyKey, actor: input.context.actor, source: input.context.source, target: input.target ?? null, payload: input.payload, preflightData: input.preflightData ?? null, reason: input.reason ?? null, refs: input.refs ?? [], dryRun: input.context.dryRun, validateOnly: input.context.validateOnly, }; } export function buildDocumentQueryEnvelope(input: { name: string; payload: T }): QueryEnvelope { return { name: input.name, payload: input.payload, }; } function getDocumentBridgeMutationFunctionName(commandName: string): string { const functionName = DOCUMENT_BRIDGE_MUTATION_FUNCTIONS[ commandName as keyof typeof DOCUMENT_BRIDGE_MUTATION_FUNCTIONS ]; if (!functionName) { throw new DocumentBridgeError( `未注册文档 bridge mutation: ${commandName}`, 500, "TRANSPORT_ERROR", ); } return functionName; } function buildDocumentCommandPayloadJson(input: { context: BridgeContext; commandName: string; workspaceId: string | null; idempotencyKey: string | null; }): string { return JSON.stringify({ kind: "command", name: input.commandName, request_id: input.context.requestId, trace_id: input.context.traceId, deployment_id: input.context.deploymentId, project_id: input.context.projectId, workspace_id: input.workspaceId, tenant_id: input.context.tenantId, idempotency_key: input.idempotencyKey, actor: { type: input.context.actor.actorType, id: input.context.actor.actorId, session_id: input.context.actor.sessionId, }, source: { channel: input.context.source.channel, client: input.context.source.client, }, }); } function buildDocumentQueryPayloadJson(input: { context: BridgeContext; queryName: string; workspaceId: string | null; }): string { return JSON.stringify({ kind: "query", name: input.queryName, request_id: input.context.requestId, trace_id: input.context.traceId, deployment_id: input.context.deploymentId, project_id: input.context.projectId, workspace_id: input.workspaceId, tenant_id: input.context.tenantId, actor_id: input.context.actor.actorId, source: { channel: input.context.source.channel, client: input.context.source.client, }, }); } export function buildDocumentBridgeMutationRequest< TPayload, TArgs extends Record, >(input: { context: BridgeContext; envelope: CommandEnvelope; mapConvexArgs: (payload: TPayload) => TArgs; }): DocumentBridgeMutationRequest { const workspaceId = input.envelope.target?.workspaceId ?? input.context.workspaceId ?? null; const idempotencyKey = input.envelope.idempotencyKey ?? input.context.idempotencyKey; return { functionName: getDocumentBridgeMutationFunctionName(input.envelope.name), deploymentId: input.context.deploymentId, projectId: input.context.projectId, workspaceId, requestId: input.context.requestId, traceId: input.context.traceId, idempotencyKey, actorId: input.context.actor.actorId, payloadJson: buildDocumentCommandPayloadJson({ context: input.context, commandName: input.envelope.name, workspaceId, idempotencyKey, }), args: input.mapConvexArgs(input.envelope.payload), }; } function getDocumentBridgeQueryFunctionName(queryName: string): string { const functionName = DOCUMENT_BRIDGE_QUERY_FUNCTIONS[ queryName as keyof typeof DOCUMENT_BRIDGE_QUERY_FUNCTIONS ]; if (!functionName) { throw new DocumentBridgeError( `未注册文档 bridge query: ${queryName}`, 500, "TRANSPORT_ERROR", ); } return functionName; } export function buildDocumentBridgeQueryRequest< TPayload, TArgs extends Record, >(input: { context: BridgeContext; envelope: QueryEnvelope; mapConvexArgs: (payload: TPayload) => TArgs; }): DocumentBridgeQueryRequest { const workspaceId = input.context.workspaceId ?? null; return { functionName: getDocumentBridgeQueryFunctionName(input.envelope.name), deploymentId: input.context.deploymentId, projectId: input.context.projectId, workspaceId, requestId: input.context.requestId, traceId: input.context.traceId, actorId: input.context.actor.actorId, payloadJson: buildDocumentQueryPayloadJson({ context: input.context, queryName: input.envelope.name, workspaceId, }), args: input.mapConvexArgs(input.envelope.payload), }; } export async function executeDocumentBridgeMutationRequest< TArgs extends Record, TResult, >(input: { client: ConvexHttpClient; mutation: unknown; request: DocumentBridgeMutationRequest; }): Promise { const mutate = input.client.mutation.bind(input.client) as ( mutation: unknown, args: TArgs, ) => Promise; return mutate(input.mutation, input.request.args); } export function assertDocumentId(documentId: string | null | undefined): string { const normalized = typeof documentId === "string" ? documentId.trim() : ""; if (!normalized) { throw new DocumentBridgeError("缺少 documentId", 400, "VALIDATION_ERROR", [ { field: "documentId", reason: "required" }, ]); } return normalized; } export function assertTitle(title: string | null | undefined): string { if (typeof title !== "string") { throw new DocumentBridgeError("缺少 title", 400, "VALIDATION_ERROR", [ { field: "title", reason: "required" }, ]); } const normalized = title.trim() || "无标题"; return normalized; } export function assertBlockId(blockId: string | null | undefined): string { const normalized = typeof blockId === "string" ? blockId.trim() : ""; if (!normalized) { throw new DocumentBridgeError("缺少 blockId", 400, "VALIDATION_ERROR", [ { field: "blockId", reason: "required" }, ]); } return normalized; } export function assertNextBlock(nextBlock: unknown): asserts nextBlock is Record { if (!nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) { throw new DocumentBridgeError("缺少 nextBlock", 400, "VALIDATION_ERROR", [ { field: "nextBlock", reason: "required object" }, ]); } } export function assertStats(stats: unknown): asserts stats is { wordCount: number; characterCount: number; blockCount: number; todoTotal: number; todoDone: number; } { if (!stats || typeof stats !== "object") { throw new DocumentBridgeError("缺少 stats", 400, "VALIDATION_ERROR", [ { field: "stats", reason: "required" }, ]); } const record = stats as Record; const fields = ["wordCount", "characterCount", "blockCount", "todoTotal", "todoDone"] as const; for (const field of fields) { if (typeof record[field] !== "number" || !Number.isFinite(record[field] as number)) { throw new DocumentBridgeError("stats 字段非法", 400, "VALIDATION_ERROR", [ { field, reason: "must be finite number" }, ]); } } } const PAGE_FONT_VALUES = new Set(["default", "song", "kai"]); const PAGE_LAYOUT_DENSITY_VALUES = new Set(["compact", "normal", "spacious"]); const PAGE_OPTION_BOOLEAN_FIELDS = [ "wideLayout", "smallText", "showHeadingNumbers", "showToc", "showStructure", "protectEditing", "showWordCount", "collapseBacklinks", "hideChildPages", "showBlockRefCount", ] as const satisfies readonly (keyof PageOptionsState)[]; const PAGE_OPTION_ALLOWED_FIELDS = new Set([ ...PAGE_OPTION_BOOLEAN_FIELDS, "pageFont", "layoutDensity", "embedDefaultBlockId", ]); export function assertOptionsPatch( options: unknown, ): asserts options is Partial> { if (!options || typeof options !== "object" || Array.isArray(options)) { throw new DocumentBridgeError("缺少 options", 400, "VALIDATION_ERROR", [ { field: "options", reason: "required object" }, ]); } const record = options as Record; const entries = Object.entries(record); if (entries.length === 0) { throw new DocumentBridgeError("缺少 options", 400, "VALIDATION_ERROR", [ { field: "options", reason: "must not be empty" }, ]); } for (const [field, value] of entries) { if (!PAGE_OPTION_ALLOWED_FIELDS.has(field as keyof PageOptionsState)) { throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [ { field, reason: "unexpected field" }, ]); } if ((PAGE_OPTION_BOOLEAN_FIELDS as readonly string[]).includes(field)) { if (typeof value !== "boolean") { throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [ { field, reason: "must be boolean" }, ]); } continue; } if (field === "pageFont") { if (typeof value !== "string" || !PAGE_FONT_VALUES.has(value as PageOptionsState["pageFont"])) { throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [ { field, reason: "must be one of default/song/kai" }, ]); } continue; } if (field === "layoutDensity") { if ( typeof value !== "string" || !PAGE_LAYOUT_DENSITY_VALUES.has(value as PageOptionsState["layoutDensity"]) ) { throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [ { field, reason: "must be one of compact/normal/spacious" }, ]); } continue; } if (field === "embedDefaultBlockId" && value !== null && typeof value !== "string") { throw new DocumentBridgeError("options 字段非法", 400, "VALIDATION_ERROR", [ { field, reason: "must be string or null" }, ]); } } } export function documentBridgeErrorResponse(error: unknown) { if (error instanceof DocumentBridgeError) { return apiErrorResponse(error.message, error.status, { code: error.code, details: error.details, }); } if (error instanceof HttpError) { return apiErrorResponse(error.message || "未登录", error.status); } return apiErrorResponse(error instanceof Error ? error.message : "服务器错误", 500); }