Chat IA: - Scroll natif avec auto-scroll vers le dernier message - Input toujours visible en bas du panneau - Remplace ScrollArea (ref cassé) par div overflow-y-auto Panneaux redimensionnables: - Bottom panel (timeline): drag vertical pour ajuster la hauteur (120-600px) - Right panel (assistant): drag horizontal pour ajuster la largeur (280-700px) - Handles visuels avec feedback hover Timeline: - Molette = zoom (sans Ctrl), Shift+molette = scroll horizontal - Zoom max augmenté à 20x - Clic droit = menu contextuel (aller à frame, set IN/OUT, effacer) - data-seq-id sur les blocs de séquence pour identification Génération IA: - Meilleur error handling avec message dans le toast (8s durée) - Console.error pour debug navigateur Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
347 lines
9.5 KiB
TypeScript
347 lines
9.5 KiB
TypeScript
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
|
import { ForbiddenError } from "@shared/_core/errors";
|
|
import axios, { type AxiosInstance } from "axios";
|
|
import { parse as parseCookieHeader } from "cookie";
|
|
import type { Request } from "express";
|
|
import { SignJWT, jwtVerify } from "jose";
|
|
import type { User } from "../../drizzle/schema";
|
|
import * as db from "../db";
|
|
import { ENV } from "./env";
|
|
import type {
|
|
ExchangeTokenRequest,
|
|
ExchangeTokenResponse,
|
|
GetUserInfoResponse,
|
|
GetUserInfoWithJwtRequest,
|
|
GetUserInfoWithJwtResponse,
|
|
} from "./types/manusTypes";
|
|
// Utility function
|
|
const isNonEmptyString = (value: unknown): value is string =>
|
|
typeof value === "string" && value.length > 0;
|
|
|
|
export type SessionPayload = {
|
|
openId: string;
|
|
appId: string;
|
|
name: string;
|
|
};
|
|
|
|
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
|
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
|
|
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
|
|
|
|
class OAuthService {
|
|
constructor(private client: ReturnType<typeof axios.create>) {
|
|
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
|
|
if (!ENV.oAuthServerUrl) {
|
|
console.error(
|
|
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
|
|
);
|
|
}
|
|
}
|
|
|
|
private decodeState(state: string): string {
|
|
const redirectUri = atob(state);
|
|
return redirectUri;
|
|
}
|
|
|
|
async getTokenByCode(
|
|
code: string,
|
|
state: string
|
|
): Promise<ExchangeTokenResponse> {
|
|
const payload: ExchangeTokenRequest = {
|
|
clientId: ENV.appId,
|
|
grantType: "authorization_code",
|
|
code,
|
|
redirectUri: this.decodeState(state),
|
|
};
|
|
|
|
const { data } = await this.client.post<ExchangeTokenResponse>(
|
|
EXCHANGE_TOKEN_PATH,
|
|
payload
|
|
);
|
|
|
|
return data;
|
|
}
|
|
|
|
async getUserInfoByToken(
|
|
token: ExchangeTokenResponse
|
|
): Promise<GetUserInfoResponse> {
|
|
const { data } = await this.client.post<GetUserInfoResponse>(
|
|
GET_USER_INFO_PATH,
|
|
{
|
|
accessToken: token.accessToken,
|
|
}
|
|
);
|
|
|
|
return data;
|
|
}
|
|
}
|
|
|
|
const createOAuthHttpClient = (): AxiosInstance =>
|
|
axios.create({
|
|
baseURL: ENV.oAuthServerUrl,
|
|
timeout: AXIOS_TIMEOUT_MS,
|
|
});
|
|
|
|
class SDKServer {
|
|
private readonly client: AxiosInstance;
|
|
private readonly oauthService: OAuthService;
|
|
|
|
constructor(client: AxiosInstance = createOAuthHttpClient()) {
|
|
this.client = client;
|
|
this.oauthService = new OAuthService(this.client);
|
|
}
|
|
|
|
private deriveLoginMethod(
|
|
platforms: unknown,
|
|
fallback: string | null | undefined
|
|
): string | null {
|
|
if (fallback && fallback.length > 0) return fallback;
|
|
if (!Array.isArray(platforms) || platforms.length === 0) return null;
|
|
const set = new Set<string>(
|
|
platforms.filter((p): p is string => typeof p === "string")
|
|
);
|
|
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
|
|
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
|
|
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
|
|
if (
|
|
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
|
|
set.has("REGISTERED_PLATFORM_AZURE")
|
|
)
|
|
return "microsoft";
|
|
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
|
|
const first = Array.from(set)[0];
|
|
return first ? first.toLowerCase() : null;
|
|
}
|
|
|
|
/**
|
|
* Exchange OAuth authorization code for access token
|
|
* @example
|
|
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
|
*/
|
|
async exchangeCodeForToken(
|
|
code: string,
|
|
state: string
|
|
): Promise<ExchangeTokenResponse> {
|
|
return this.oauthService.getTokenByCode(code, state);
|
|
}
|
|
|
|
/**
|
|
* Get user information using access token
|
|
* @example
|
|
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
|
*/
|
|
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
|
|
const data = await this.oauthService.getUserInfoByToken({
|
|
accessToken,
|
|
} as ExchangeTokenResponse);
|
|
const loginMethod = this.deriveLoginMethod(
|
|
(data as any)?.platforms,
|
|
(data as any)?.platform ?? data.platform ?? null
|
|
);
|
|
return {
|
|
...(data as any),
|
|
platform: loginMethod,
|
|
loginMethod,
|
|
} as GetUserInfoResponse;
|
|
}
|
|
|
|
private parseCookies(cookieHeader: string | undefined) {
|
|
if (!cookieHeader) {
|
|
return new Map<string, string>();
|
|
}
|
|
|
|
const parsed = parseCookieHeader(cookieHeader);
|
|
return new Map(Object.entries(parsed));
|
|
}
|
|
|
|
private getSessionSecret() {
|
|
const secret = ENV.cookieSecret;
|
|
return new TextEncoder().encode(secret);
|
|
}
|
|
|
|
/**
|
|
* Create a session token for a Manus user openId
|
|
* @example
|
|
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
|
|
*/
|
|
async createSessionToken(
|
|
openId: string,
|
|
options: { expiresInMs?: number; name?: string } = {}
|
|
): Promise<string> {
|
|
return this.signSession(
|
|
{
|
|
openId,
|
|
appId: ENV.appId,
|
|
name: options.name || "",
|
|
},
|
|
options
|
|
);
|
|
}
|
|
|
|
async signSession(
|
|
payload: SessionPayload,
|
|
options: { expiresInMs?: number } = {}
|
|
): Promise<string> {
|
|
const issuedAt = Date.now();
|
|
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
|
|
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
|
|
const secretKey = this.getSessionSecret();
|
|
|
|
return new SignJWT({
|
|
openId: payload.openId,
|
|
appId: payload.appId,
|
|
name: payload.name,
|
|
})
|
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
|
.setExpirationTime(expirationSeconds)
|
|
.sign(secretKey);
|
|
}
|
|
|
|
async verifySession(
|
|
cookieValue: string | undefined | null
|
|
): Promise<{ openId: string; appId: string; name: string } | null> {
|
|
if (!cookieValue) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const secretKey = this.getSessionSecret();
|
|
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
|
algorithms: ["HS256"],
|
|
});
|
|
const { openId, appId, name } = payload as Record<string, unknown>;
|
|
|
|
if (
|
|
!isNonEmptyString(openId) ||
|
|
!isNonEmptyString(appId) ||
|
|
!isNonEmptyString(name)
|
|
) {
|
|
console.warn("[Auth] Session payload missing required fields");
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
openId,
|
|
appId,
|
|
name,
|
|
};
|
|
} catch (error) {
|
|
console.warn("[Auth] Session verification failed", String(error));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getUserInfoWithJwt(
|
|
jwtToken: string
|
|
): Promise<GetUserInfoWithJwtResponse> {
|
|
const payload: GetUserInfoWithJwtRequest = {
|
|
jwtToken,
|
|
projectId: ENV.appId,
|
|
};
|
|
|
|
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
|
|
GET_USER_INFO_WITH_JWT_PATH,
|
|
payload
|
|
);
|
|
|
|
const loginMethod = this.deriveLoginMethod(
|
|
(data as any)?.platforms,
|
|
(data as any)?.platform ?? data.platform ?? null
|
|
);
|
|
return {
|
|
...(data as any),
|
|
platform: loginMethod,
|
|
loginMethod,
|
|
} as GetUserInfoWithJwtResponse;
|
|
}
|
|
|
|
async authenticateRequest(req: Request): Promise<AuthenticatedUser> {
|
|
// Regular authentication flow
|
|
const cookies = this.parseCookies(req.headers.cookie);
|
|
const sessionCookie = cookies.get(COOKIE_NAME);
|
|
const session = await this.verifySession(sessionCookie);
|
|
|
|
if (!session) {
|
|
throw ForbiddenError("Invalid session cookie");
|
|
}
|
|
|
|
if (session.openId.startsWith(CRON_OPEN_ID_PREFIX)) {
|
|
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
|
const taskUid = userInfo.taskUid ?? null;
|
|
if (!taskUid) {
|
|
throw ForbiddenError("Cron session missing task_uid");
|
|
}
|
|
return buildCronUser(userInfo);
|
|
}
|
|
|
|
const sessionUserId = session.openId;
|
|
const now = Date.now();
|
|
|
|
const cached = userCache.get(sessionUserId);
|
|
if (cached && (now - cached.cachedAt) < USER_CACHE_TTL) {
|
|
return cached.user;
|
|
}
|
|
|
|
let user = await db.getUserByOpenId(sessionUserId);
|
|
|
|
if (user) {
|
|
userCache.set(sessionUserId, { user, cachedAt: now });
|
|
return user;
|
|
}
|
|
|
|
// User not in DB - try OAuth sync (only for non-local users)
|
|
if (!sessionUserId.startsWith("local:")) {
|
|
try {
|
|
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
|
await db.upsertUser({
|
|
openId: userInfo.openId,
|
|
name: userInfo.name || null,
|
|
email: userInfo.email ?? null,
|
|
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
|
lastSignedIn: new Date(),
|
|
});
|
|
user = await db.getUserByOpenId(userInfo.openId);
|
|
} catch (error) {
|
|
console.error("[Auth] Failed to sync user from OAuth:", error);
|
|
}
|
|
}
|
|
|
|
if (!user) {
|
|
throw ForbiddenError("User not found");
|
|
}
|
|
|
|
return user;
|
|
}
|
|
}
|
|
|
|
const CRON_OPEN_ID_PREFIX = "cron_";
|
|
|
|
const userCache = new Map<string, { user: User; cachedAt: number }>();
|
|
const USER_CACHE_TTL = 60_000;
|
|
|
|
/** Result of `sdk.authenticateRequest`. Cron callbacks set `isCron=true` and `taskUid`; see `references/periodic-updates.md`. */
|
|
export type AuthenticatedUser = User & {
|
|
taskUid?: string;
|
|
isCron?: boolean;
|
|
};
|
|
|
|
function buildCronUser(
|
|
userInfo: GetUserInfoWithJwtResponse
|
|
): AuthenticatedUser {
|
|
const now = new Date();
|
|
return {
|
|
id: -1,
|
|
openId: userInfo.openId,
|
|
name: userInfo.name || "Manus Scheduled Task",
|
|
email: null,
|
|
loginMethod: null,
|
|
role: "user",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastSignedIn: now,
|
|
taskUid: userInfo.taskUid ?? undefined,
|
|
isCron: true,
|
|
} as AuthenticatedUser;
|
|
}
|
|
|
|
export const sdk = new SDKServer();
|