Files
trade-message-center/apps/server/src/http/onetalk.ts
T

488 lines
18 KiB
TypeScript

// 提供 Bright 受权会话读取 HTTP 边界
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import {
ONETALK_CONVERSATIONS_ROUTE,
ONETALK_CONVERSATION_ROUTE,
ONETALK_HISTORY_ROUTE,
ONETALK_PROTOCOL_VERSION,
} from "@trade-message-center/onetalk-contract";
import type {
OneTalkAuthorizationDecision,
OneTalkAuthorizationReader,
OneTalkMindScope,
} from "@trade-message-center/onetalk-contract";
import {
OneTalkDatabaseError,
type CenterConversation,
type CenterMessage,
type OneTalkHistoryReadPurpose,
type OneTalkReadService,
} from "../onetalk/index.ts";
import type { OneTalkConnectionRegistry } from "../websocket/registry.ts";
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
const HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS = 30;
const MINIMUM_PAGE_LIMIT = 1;
const MAXIMUM_PAGE_LIMIT = 100;
const CORS_ALLOWED_REQUEST_HEADERS = new Set(["content-type"]);
const CORS_ALLOWED_REQUEST_METHOD = "GET";
type AccountParams = {
channelAccountId: string;
};
type ConversationParams = AccountParams & {
conversationId: string;
};
export type BrightConversationListQuery = {
cursor?: unknown;
limit?: unknown;
query?: unknown;
};
export type BrightHistoryQuery = {
cursor?: unknown;
fromSentAtMs?: unknown;
limit?: unknown;
toSentAtMs?: unknown;
};
export type BrightReadErrorCode =
| "auth_required"
| "authorization_rejected"
| "authorization_unavailable"
| "authorization_version_changed"
| "binding_revoked"
| "conversation_not_found"
| "database_unavailable"
| "history_incomplete"
| "internal_error"
| "invalid_cursor"
| "invalid_limit"
| "invalid_time_range"
| "scope_mismatch";
export type BrightReadErrorResponse = {
error: {
code: BrightReadErrorCode;
};
};
export type BrightPluginStatus = {
status: "online" | "offline";
};
export type BrightConversationListResponse = {
scope: OneTalkMindScope;
plugin: BrightPluginStatus;
conversations: CenterConversation[];
page: {
hasMore: boolean;
nextCursor: string | null;
};
};
export type BrightConversationResponse = {
scope: OneTalkMindScope;
plugin: BrightPluginStatus;
conversation: CenterConversation;
};
export type BrightHistoryResponse = {
scope: OneTalkMindScope;
conversationId: string;
messages: CenterMessage[];
page: {
hasMore: boolean;
nextCursor: string | null;
};
};
export type BrightReadRouteOptions = {
authorization: OneTalkAuthorizationReader;
readService: OneTalkReadService;
registry?: OneTalkConnectionRegistry;
mindPageOrigin?: string;
cutoverPolicy: OneTalkCutoverPolicy;
};
type AuthorizationResult =
| { ok: true; scope: OneTalkMindScope }
| {
ok: false;
statusCode: 401 | 403 | 503;
code:
| "auth_required"
| "authorization_rejected"
| "authorization_unavailable"
| "authorization_version_changed"
| "binding_revoked"
| "scope_mismatch";
};
type AuthorizedReadScopeResult =
| { ok: true; scope: OneTalkMindScope }
| { ok: false; response: FastifyReply };
const authorizationCodeFor = (decision: OneTalkAuthorizationDecision): AuthorizationResult => {
if (decision.allowed) return { ok: true, scope: decision.mindScope };
const statusCode =
decision.code === "authorization_unavailable"
? 503
: decision.code === "auth_required"
? 401
: 403;
return { ok: false, statusCode, code: decision.code };
};
const authorizeRead = async (
request: FastifyRequest,
authorization: OneTalkAuthorizationReader,
channelAccountId: string,
): Promise<AuthorizationResult> => {
if (channelAccountId.trim() === "") {
return { ok: false, statusCode: 403, code: "scope_mismatch" };
}
const cookie = typeof request.headers.cookie === "string" ? request.headers.cookie : undefined;
let decision: OneTalkAuthorizationDecision;
try {
decision = await authorization.authorize({
connectionType: "mind_page",
operation: "read",
scope: { channelAccountId },
...(cookie === undefined ? {} : { cookie }),
});
} catch {
return { ok: false, statusCode: 503, code: "authorization_unavailable" };
}
if (!decision.allowed) return authorizationCodeFor(decision);
if (decision.mindScope.channelAccountId !== channelAccountId) {
return { ok: false, statusCode: 403, code: "scope_mismatch" };
}
if (!decision.permissions.includes("read")) {
return { ok: false, statusCode: 403, code: "authorization_rejected" };
}
return { ok: true, scope: decision.mindScope };
};
export const sendError = (
reply: FastifyReply,
statusCode: number,
code: BrightReadErrorCode,
): FastifyReply => reply.code(statusCode).send({ error: { code } });
const authorizeRequestScope = async (
request: FastifyRequest,
reply: FastifyReply,
authorization: OneTalkAuthorizationReader,
channelAccountId: string,
): Promise<AuthorizedReadScopeResult> => {
const authorizationResult = await authorizeRead(request, authorization, channelAccountId);
if (!authorizationResult.ok) {
return {
ok: false,
response: sendError(reply, authorizationResult.statusCode, authorizationResult.code),
};
}
return { ok: true, scope: authorizationResult.scope };
};
const applyCors = (reply: FastifyReply, request: FastifyRequest, origin?: string): void => {
if (origin === undefined || request.headers.origin !== origin) return;
reply.header("access-control-allow-origin", origin);
reply.header("access-control-allow-credentials", "true");
reply.header("vary", "Origin");
};
const rejectUnexpectedOrigin = (
request: FastifyRequest,
reply: FastifyReply,
origin?: string,
): FastifyReply | null => {
const requestOrigin = request.headers.origin;
if (origin === undefined || (requestOrigin !== undefined && requestOrigin !== origin)) {
return sendError(reply, 403, "scope_mismatch");
}
return null;
};
const pluginStatusFor = (
registry: OneTalkConnectionRegistry | undefined,
scope: OneTalkMindScope,
): BrightPluginStatus => ({ status: registry?.isPluginOnline(scope) ? "online" : "offline" });
export const parseLimit = (value: unknown): number | undefined | null => {
if (value === undefined) return undefined;
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
const limit = Number(value);
return Number.isSafeInteger(limit) && limit >= MINIMUM_PAGE_LIMIT && limit <= MAXIMUM_PAGE_LIMIT
? limit
: null;
};
export const parseOptionalSafeInteger = (value: unknown): number | undefined | null => {
if (value === undefined) return undefined;
if (typeof value !== "string" || !/^-?\d+$/.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
};
export const parseOpaqueCursor = (value: unknown): string | undefined | null => {
if (value === undefined) return undefined;
return typeof value === "string" ? value : null;
};
const parseQuery = (value: unknown): string | undefined | null => {
if (value === undefined) return undefined;
return typeof value === "string" ? value.trim() : null;
};
const allowsCorsRequestHeaders = (value: string | string[] | undefined): boolean => {
if (value === undefined) return true;
if (Array.isArray(value)) return false;
const requestedHeaders = value.split(",").map((header) => header.trim().toLowerCase());
return (
requestedHeaders.length > 0 &&
requestedHeaders.every(
(header) => header.length > 0 && CORS_ALLOWED_REQUEST_HEADERS.has(header),
)
);
};
const allowsCorsRequestMethod = (value: string | string[] | undefined): boolean => {
if (value === undefined) return true;
return value === CORS_ALLOWED_REQUEST_METHOD;
};
export const handleReadFailure = (reply: FastifyReply, error: unknown): FastifyReply | null => {
if (error instanceof OneTalkDatabaseError) {
return sendError(reply, 503, "database_unavailable");
}
return null;
};
type HistoryReadSuccess = {
conversationId: string;
messages: CenterMessage[];
page: { hasMore: boolean; nextCursor: string | null };
};
export type HistoryReadReplyOptions = {
readService: OneTalkReadService;
scope: Pick<OneTalkMindScope, "channelAccountId">;
conversationId: string;
query: BrightHistoryQuery;
purpose: OneTalkHistoryReadPurpose;
isAdmitted: () => boolean;
toResponse: (result: HistoryReadSuccess) => unknown;
};
/** 执行历史读取并映射共享的参数、分页与稳定错误语义。 */
export const replyWithHistoryRead = async (
reply: FastifyReply,
options: HistoryReadReplyOptions,
): Promise<FastifyReply> => {
const fromSentAtMs = parseOptionalSafeInteger(options.query.fromSentAtMs);
const toSentAtMs = parseOptionalSafeInteger(options.query.toSentAtMs);
const limit = parseLimit(options.query.limit);
const cursor = parseOpaqueCursor(options.query.cursor);
if (fromSentAtMs === null || toSentAtMs === null) {
return sendError(reply, 400, "invalid_time_range");
}
if (fromSentAtMs !== undefined && toSentAtMs !== undefined && fromSentAtMs >= toSentAtMs) {
return sendError(reply, 400, "invalid_time_range");
}
if (
options.purpose === "communication_summary_read" &&
(fromSentAtMs === undefined || toSentAtMs === undefined)
) {
return sendError(reply, 400, "invalid_time_range");
}
if (limit === null) return sendError(reply, 400, "invalid_limit");
if (cursor === null) return sendError(reply, 400, "invalid_cursor");
try {
const result = await options.readService.readHistory({
scope: options.scope,
conversationId: options.conversationId,
...(fromSentAtMs === undefined ? {} : { fromSentAtMs }),
...(toSentAtMs === undefined ? {} : { toSentAtMs }),
...(limit === undefined ? {} : { limit }),
...(cursor === undefined ? {} : { cursor }),
purpose: options.purpose,
});
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
if (result.status === "not_found") return sendError(reply, 404, "conversation_not_found");
if (result.status === "rejected") {
if (result.reason === "history_incomplete") {
reply.header("retry-after", HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS);
return sendError(reply, 503, result.reason);
}
return sendError(reply, 400, result.reason);
}
return reply.send(options.toResponse(result));
} catch (error: unknown) {
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
}
};
/** 安装会话列表、详情与基于领域 opaque cursor 的消息读取路由。 */
export const installOneTalkReadRoutes = (
app: FastifyInstance,
options: BrightReadRouteOptions,
): void => {
const requestEpochs = new WeakMap<FastifyRequest, number>();
const requestIsAdmitted = (request: FastifyRequest): boolean => {
const epoch = requestEpochs.get(request);
return (
epoch !== undefined &&
options.cutoverPolicy.isCurrent(epoch) &&
options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)
);
};
const mindOriginGuard = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
return void sendError(reply, 503, "authorization_unavailable");
}
requestEpochs.set(request, options.cutoverPolicy.capture());
if (rejectUnexpectedOrigin(request, reply, options.mindPageOrigin)) return;
applyCors(reply, request, options.mindPageOrigin);
};
app.options("/api/bright/onetalk/*", async (request, reply) => {
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
return sendError(reply, 503, "authorization_unavailable");
}
if (
options.mindPageOrigin === undefined ||
request.headers.origin !== options.mindPageOrigin ||
!allowsCorsRequestMethod(request.headers["access-control-request-method"]) ||
!allowsCorsRequestHeaders(request.headers["access-control-request-headers"])
) {
return reply.code(403).send();
}
reply.header("access-control-allow-origin", options.mindPageOrigin);
reply.header("access-control-allow-credentials", "true");
reply.header("vary", "Origin");
reply.header("access-control-allow-methods", "GET,OPTIONS");
reply.header("access-control-allow-headers", "content-type");
return reply.code(204).send();
});
app.get<{ Params: AccountParams; Querystring: BrightConversationListQuery }>(
ONETALK_CONVERSATIONS_ROUTE,
{ preHandler: mindOriginGuard },
async (request, reply) => {
const authorizedScope = await authorizeRequestScope(
request,
reply,
options.authorization,
request.params.channelAccountId,
);
if (!authorizedScope.ok) return authorizedScope.response;
if (!requestIsAdmitted(request))
return sendError(reply, 503, "authorization_unavailable");
const query = parseQuery(request.query.query);
const limit = parseLimit(request.query.limit);
const cursor = parseOpaqueCursor(request.query.cursor);
if (query === null || limit === null) return sendError(reply, 400, "invalid_limit");
if (cursor === null) return sendError(reply, 400, "invalid_cursor");
try {
const result = await options.readService.listConversations({
scope: authorizedScope.scope,
...(query === undefined ? {} : { query }),
...(limit === undefined ? {} : { limit }),
...(cursor === undefined ? {} : { cursor }),
});
if (!requestIsAdmitted(request)) {
return sendError(reply, 503, "authorization_unavailable");
}
if (result.status === "rejected") return sendError(reply, 400, result.reason);
return reply.send({
scope: authorizedScope.scope,
plugin: pluginStatusFor(options.registry, authorizedScope.scope),
conversations: result.conversations,
page: result.page,
} satisfies BrightConversationListResponse);
} catch (error: unknown) {
if (!requestIsAdmitted(request)) {
return sendError(reply, 503, "authorization_unavailable");
}
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
}
},
);
app.get<{ Params: ConversationParams }>(
ONETALK_CONVERSATION_ROUTE,
{ preHandler: mindOriginGuard },
async (request, reply) => {
const authorizedScope = await authorizeRequestScope(
request,
reply,
options.authorization,
request.params.channelAccountId,
);
if (!authorizedScope.ok) return authorizedScope.response;
if (!requestIsAdmitted(request))
return sendError(reply, 503, "authorization_unavailable");
try {
const result = await options.readService.readConversation({
scope: authorizedScope.scope,
conversationId: request.params.conversationId,
});
if (!requestIsAdmitted(request)) {
return sendError(reply, 503, "authorization_unavailable");
}
if (result.status === "not_found")
return sendError(reply, 404, "conversation_not_found");
return reply.send({
scope: authorizedScope.scope,
plugin: pluginStatusFor(options.registry, authorizedScope.scope),
conversation: result.conversation,
} satisfies BrightConversationResponse);
} catch (error: unknown) {
if (!requestIsAdmitted(request)) {
return sendError(reply, 503, "authorization_unavailable");
}
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
}
},
);
app.get<{ Params: ConversationParams; Querystring: BrightHistoryQuery }>(
ONETALK_HISTORY_ROUTE,
{ preHandler: mindOriginGuard },
async (request, reply) => {
const authorizedScope = await authorizeRequestScope(
request,
reply,
options.authorization,
request.params.channelAccountId,
);
if (!authorizedScope.ok) return authorizedScope.response;
if (!requestIsAdmitted(request))
return sendError(reply, 503, "authorization_unavailable");
return replyWithHistoryRead(reply, {
readService: options.readService,
scope: authorizedScope.scope,
conversationId: request.params.conversationId,
query: request.query,
purpose: "normal",
isAdmitted: () => requestIsAdmitted(request),
toResponse: (result) =>
({
scope: authorizedScope.scope,
conversationId: result.conversationId,
messages: result.messages,
page: result.page,
}) satisfies BrightHistoryResponse,
});
},
);
};