实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。 实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
This commit is contained in:
@@ -8,12 +8,21 @@ import { NIANCODE_AUTH_CONFIG } from '../auth-config';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import {
|
||||
clearWorksSquareSession,
|
||||
storeWorksSquareSession,
|
||||
storeWorksSquareSessionFromTokenPayload,
|
||||
commitWorksSquareSession,
|
||||
commitWorksSquareSessionFromTokenPayload,
|
||||
discardUnrestorableWorksSquareSession,
|
||||
flushWorksSquareSessionPersistence,
|
||||
getValidWorksSquareAccessToken,
|
||||
getWorksSquareSessionRestoreStatus,
|
||||
getWorksSquareSessionSnapshot,
|
||||
markWorksSquareSessionActive,
|
||||
retryWorksSquareSessionRestore,
|
||||
} from '../../services/works-square-session';
|
||||
import { clearWorksSquareAIGatewayCredential } from '../../services/works-square-ai-gateway';
|
||||
import { getProviderService } from '../../services/providers/provider-service';
|
||||
import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../../shared/user-model-config';
|
||||
import {
|
||||
clearManagedWorksSquareRuntime,
|
||||
clearManagedWorksSquareRuntimeBestEffort,
|
||||
ensureManagedWorksSquareRuntimeClean,
|
||||
} from '../../services/works-square-runtime';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { WorksSquareTokenPayload } from '../../services/works-square-session';
|
||||
|
||||
@@ -31,10 +40,6 @@ type PasswordLoginInput = AuthClientInput & {
|
||||
scope?: unknown;
|
||||
};
|
||||
|
||||
type RefreshInput = AuthClientInput & {
|
||||
refreshToken?: unknown;
|
||||
};
|
||||
|
||||
type LogoutInput = {
|
||||
authBase?: unknown;
|
||||
accessToken?: unknown;
|
||||
@@ -45,6 +50,11 @@ type SessionSyncInput = {
|
||||
refreshToken?: unknown;
|
||||
tokenType?: unknown;
|
||||
expiresAt?: unknown;
|
||||
lastActiveAt?: unknown;
|
||||
};
|
||||
|
||||
type SessionRefreshInput = {
|
||||
forceRefresh?: unknown;
|
||||
};
|
||||
|
||||
type DesktopAuthStartPayload = {
|
||||
@@ -85,6 +95,16 @@ function readOptionalNumber(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOptionalBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function withoutRefreshToken(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
||||
const { refresh_token: _refreshToken, ...publicPayload } = payload as Record<string, unknown>;
|
||||
return publicPayload;
|
||||
}
|
||||
|
||||
function normalizeAuthBase(value: unknown = NIANCODE_AUTH_CONFIG.gatewayAuthUrl): string {
|
||||
const authBase = readOptionalString(value, NIANCODE_AUTH_CONFIG.gatewayAuthUrl).replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(authBase)) {
|
||||
@@ -231,7 +251,11 @@ async function pollDesktopAuthToken(
|
||||
throw new Error('Authorization timed out');
|
||||
}
|
||||
|
||||
async function handleBrowserAuthorization(res: ServerResponse): Promise<void> {
|
||||
async function handleBrowserAuthorization(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
|
||||
const response = await proxyAwareFetch(createWorksUrl('/api/auth/desktop/start').toString(), {
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -257,13 +281,19 @@ async function handleBrowserAuthorization(res: ServerResponse): Promise<void> {
|
||||
|
||||
await shell.openExternal(authorizeUrl);
|
||||
const token = await pollDesktopAuthToken(requestId, deviceSecret, pollIntervalMs);
|
||||
let session = null;
|
||||
if (token && typeof token === 'object' && !Array.isArray(token)) {
|
||||
storeWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload);
|
||||
session = await commitWorksSquareSessionFromTokenPayload(token as WorksSquareTokenPayload);
|
||||
}
|
||||
sendJson(res, 200, { success: true, token });
|
||||
sendJson(res, 200, { success: true, token: withoutRefreshToken(token), session });
|
||||
}
|
||||
|
||||
async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
async function handlePasswordLogin(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureRuntimeReadyForLogin(res, ctx)) return;
|
||||
const body = await parseJsonBody<PasswordLoginInput>(req);
|
||||
const authBase = normalizeAuthBase(body.authBase);
|
||||
const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId);
|
||||
@@ -303,91 +333,171 @@ async function handlePasswordLogin(req: IncomingMessage, res: ServerResponse): P
|
||||
return;
|
||||
}
|
||||
|
||||
let session = null;
|
||||
if (
|
||||
tokenResult.payload
|
||||
&& typeof tokenResult.payload === 'object'
|
||||
&& !Array.isArray(tokenResult.payload)
|
||||
) {
|
||||
storeWorksSquareSessionFromTokenPayload(tokenResult.payload as WorksSquareTokenPayload);
|
||||
session = await commitWorksSquareSessionFromTokenPayload(
|
||||
tokenResult.payload as WorksSquareTokenPayload,
|
||||
);
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, token: tokenResult.payload });
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
token: withoutRefreshToken(tokenResult.payload),
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRefresh(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<RefreshInput>(req);
|
||||
const authBase = normalizeAuthBase(body.authBase);
|
||||
const clientId = readOptionalString(body.clientId, NIANCODE_AUTH_CONFIG.clientId);
|
||||
const clientSecret = readOptionalString(body.clientSecret, NIANCODE_AUTH_CONFIG.clientSecret);
|
||||
const refreshToken = readRequiredString(body.refreshToken, 'refreshToken');
|
||||
|
||||
const tokenResult = await requestToken(
|
||||
authBase,
|
||||
clientId,
|
||||
clientSecret,
|
||||
createTokenRequestBody({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!tokenResult.ok) {
|
||||
sendJson(res, tokenResult.status === 401 ? 401 : 502, {
|
||||
success: false,
|
||||
error: getErrorMessage(tokenResult.payload, `Refresh failed (${tokenResult.status})`),
|
||||
});
|
||||
async function handleSessionSync(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
tokenResult.payload
|
||||
&& typeof tokenResult.payload === 'object'
|
||||
&& !Array.isArray(tokenResult.payload)
|
||||
) {
|
||||
storeWorksSquareSessionFromTokenPayload(tokenResult.payload as WorksSquareTokenPayload, refreshToken);
|
||||
try {
|
||||
await flushWorksSquareSessionPersistence();
|
||||
} catch (error) {
|
||||
logger.warn('[auth] Session persistence is temporarily unavailable during sync', error);
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true, token: tokenResult.payload });
|
||||
}
|
||||
|
||||
async function handleSessionSync(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<SessionSyncInput>(req);
|
||||
storeWorksSquareSession({
|
||||
accessToken: readRequiredString(body.accessToken, 'accessToken'),
|
||||
refreshToken: readOptionalTrimmedString(body.refreshToken),
|
||||
tokenType: readOptionalTrimmedString(body.tokenType),
|
||||
expiresAt: readOptionalNumber(body.expiresAt),
|
||||
});
|
||||
let session = getWorksSquareSessionSnapshot();
|
||||
const legacyAccessToken = readOptionalTrimmedString(body.accessToken);
|
||||
if (!session && legacyAccessToken) {
|
||||
try {
|
||||
await ensureManagedWorksSquareRuntimeClean(ctx);
|
||||
session = await commitWorksSquareSession({
|
||||
accessToken: legacyAccessToken,
|
||||
refreshToken: readOptionalTrimmedString(body.refreshToken),
|
||||
tokenType: readOptionalTrimmedString(body.tokenType),
|
||||
expiresAt: readOptionalNumber(body.expiresAt),
|
||||
lastActiveAt: readOptionalNumber(body.lastActiveAt),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('[auth] Failed to persist a migrated Renderer session', error);
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法同步,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(res, 200, { success: true });
|
||||
if (!session) {
|
||||
sendJson(res, 200, { success: true, session: null });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { success: true, session });
|
||||
}
|
||||
|
||||
async function clearManagedWorksSquareRuntime(ctx: HostApiContext): Promise<void> {
|
||||
const errors: string[] = [];
|
||||
|
||||
async function handleSessionRefresh(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
const body = await parseJsonBody<SessionRefreshInput>(req);
|
||||
try {
|
||||
await ctx.imageWorkspace?.closeEventSessions?.();
|
||||
const accessToken = await getValidWorksSquareAccessToken({
|
||||
forceRefresh: readOptionalBoolean(body.forceRefresh, true),
|
||||
});
|
||||
const session = getWorksSquareSessionSnapshot();
|
||||
if (accessToken && session) {
|
||||
sendJson(res, 200, { success: true, session });
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
await clearManagedWorksSquareRuntimeBestEffort(ctx, 'terminal session refresh');
|
||||
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' });
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
logger.warn('[auth] Failed to refresh the managed Works Square session', error);
|
||||
sendJson(res, 502, { success: false, error: '登录续期暂时失败,请稍后重试。' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionActivity(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<void> {
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
sendJson(res, 503, { success: false, error: '登录状态暂时无法恢复,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
let session;
|
||||
try {
|
||||
await ctx.opencodeManager.stop();
|
||||
session = await markWorksSquareSessionActive();
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
logger.warn('[auth] Failed to persist Works Square session activity', error);
|
||||
sendJson(res, 503, { success: false, error: '登录活动暂时无法保存,请稍后重试。' });
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
await clearManagedWorksSquareRuntimeBestEffort(ctx, 'idle session activity');
|
||||
sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
clearWorksSquareAIGatewayCredential();
|
||||
async function ensureWorksSquareSessionRestored(): Promise<boolean> {
|
||||
if (getWorksSquareSessionRestoreStatus() === 'unavailable') {
|
||||
await retryWorksSquareSessionRestore();
|
||||
}
|
||||
return getWorksSquareSessionRestoreStatus() === 'ready';
|
||||
}
|
||||
|
||||
async function ensureRuntimeReadyForLogin(
|
||||
res: ServerResponse,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
let discardedUnrestorableSession = false;
|
||||
if (!await ensureWorksSquareSessionRestored()) {
|
||||
if (!await discardUnrestorableWorksSquareSession()) {
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
error: '登录状态暂时无法恢复,请稍后重试。',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
discardedUnrestorableSession = true;
|
||||
}
|
||||
try {
|
||||
await getProviderService().deleteAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
||||
if (discardedUnrestorableSession) {
|
||||
await clearManagedWorksSquareRuntime(ctx, undefined, true);
|
||||
}
|
||||
await ensureManagedWorksSquareRuntimeClean(ctx);
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
logger.error('[auth] Failed to clean the previous runtime before login', error);
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
error: '上次登录的本地运行环境尚未清理完成,请稍后重试。',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to clear managed Works Square runtime state: ${errors.join('; ')}`);
|
||||
}
|
||||
async function handleSessionClear(res: ServerResponse, ctx: HostApiContext): Promise<void> {
|
||||
const accessToken = getWorksSquareSessionSnapshot()?.accessToken;
|
||||
clearWorksSquareSession();
|
||||
await Promise.all([
|
||||
flushWorksSquareSessionPersistence(),
|
||||
clearManagedWorksSquareRuntime(ctx, accessToken),
|
||||
]);
|
||||
sendJson(res, 200, { success: true });
|
||||
}
|
||||
|
||||
async function handleLogout(
|
||||
@@ -397,16 +507,24 @@ async function handleLogout(
|
||||
): Promise<void> {
|
||||
const body = await parseJsonBody<LogoutInput>(req);
|
||||
const authBase = normalizeAuthBase(body.authBase);
|
||||
const accessToken = readRequiredString(body.accessToken, 'accessToken');
|
||||
const rendererAccessToken = readOptionalTrimmedString(body.accessToken);
|
||||
const accessToken = getWorksSquareSessionSnapshot()?.accessToken
|
||||
?? readRequiredString(rendererAccessToken, 'accessToken');
|
||||
|
||||
let cleanupError: Error | null = null;
|
||||
clearWorksSquareSession();
|
||||
try {
|
||||
await clearManagedWorksSquareRuntime(ctx);
|
||||
await clearManagedWorksSquareRuntime(ctx, accessToken);
|
||||
} catch (error) {
|
||||
cleanupError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('[auth] Failed to clear managed Works Square runtime state during logout', cleanupError);
|
||||
}
|
||||
clearWorksSquareSession();
|
||||
try {
|
||||
await flushWorksSquareSessionPersistence();
|
||||
} catch (error) {
|
||||
cleanupError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('[auth] Failed to persist the local session clear during logout', cleanupError);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -459,22 +577,32 @@ export async function handleAuthRoutes(
|
||||
|
||||
try {
|
||||
if (url.pathname === '/api/auth/browser/start' && req.method === 'POST') {
|
||||
await handleBrowserAuthorization(res);
|
||||
await handleBrowserAuthorization(res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
||||
await handlePasswordLogin(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/refresh' && req.method === 'POST') {
|
||||
await handleRefresh(req, res);
|
||||
await handlePasswordLogin(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/sync' && req.method === 'POST') {
|
||||
await handleSessionSync(req, res);
|
||||
await handleSessionSync(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/refresh' && req.method === 'POST') {
|
||||
await handleSessionRefresh(req, res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/activity' && req.method === 'POST') {
|
||||
await handleSessionActivity(res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/session/clear' && req.method === 'POST') {
|
||||
await handleSessionClear(res, ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ export type DesignWorkspaceEventSubscriptionInput = {
|
||||
afterEventId?: string;
|
||||
};
|
||||
|
||||
export type CloseEventSessionsOptions = {
|
||||
accessToken?: string;
|
||||
tolerateRemoteFailure?: boolean;
|
||||
};
|
||||
|
||||
export class DesignWorkspaceModuleError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
@@ -47,7 +52,7 @@ export interface DesignWorkspaceModule {
|
||||
openWorkspaceEvents?(
|
||||
input: DesignWorkspaceEventSubscriptionInput,
|
||||
): Promise<DesignWorkspaceEventSubscription>;
|
||||
closeEventSessions?(): Promise<void>;
|
||||
closeEventSessions?(options?: CloseEventSessionsOptions): Promise<void>;
|
||||
openAssetContent(workspaceId: string, assetId: string, range?: string): Promise<Response>;
|
||||
reset?(): Promise<DesignWorkspaceBootstrap>;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
|
||||
import {
|
||||
DesignWorkspaceModuleError,
|
||||
type CloseEventSessionsOptions,
|
||||
type DesignWorkspaceEventSubscription,
|
||||
type DesignWorkspaceEventSubscriptionInput,
|
||||
type DesignWorkspaceModule,
|
||||
@@ -1114,7 +1115,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
};
|
||||
}
|
||||
|
||||
async closeEventSessions(): Promise<void> {
|
||||
async closeEventSessions(options: CloseEventSessionsOptions = {}): Promise<void> {
|
||||
this.eventSessionsEnabled = false;
|
||||
const activeSubscriptions = [...this.eventSubscriptionClosers.values()]
|
||||
.flatMap((closers) => [...closers]);
|
||||
@@ -1122,27 +1123,42 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
for (const close of activeSubscriptions) close();
|
||||
const pendingSessions = [...this.eventSessions.entries()];
|
||||
this.eventSessions.clear();
|
||||
const sessions = await Promise.allSettled(
|
||||
pendingSessions.map(async ([workspaceId, pending]) => ({
|
||||
workspaceId,
|
||||
session: await pending,
|
||||
})),
|
||||
);
|
||||
const closeResults = await Promise.allSettled(
|
||||
sessions.flatMap((result) => (
|
||||
result.status === 'fulfilled'
|
||||
? [this.closeEventSession(result.value.session.session_id)
|
||||
.then(() => this.rotateEventSession(result.value.workspaceId))]
|
||||
: []
|
||||
)),
|
||||
);
|
||||
const uncertainCreations = sessions.filter((result) => (
|
||||
result.status === 'rejected'
|
||||
&& !(result.reason instanceof DesignWorkspaceModuleError
|
||||
&& result.reason.code === 'DESIGN_EVENT_SESSION_CLOSED')
|
||||
const sessions = await Promise.all(pendingSessions.map(async ([workspaceId, pending]) => {
|
||||
try {
|
||||
return { workspaceId, session: await pending, creationError: null as unknown };
|
||||
} catch (error) {
|
||||
return { workspaceId, session: null, creationError: error };
|
||||
}
|
||||
}));
|
||||
const closeResults = await Promise.all(sessions.map(async ({
|
||||
workspaceId,
|
||||
session,
|
||||
creationError,
|
||||
}) => {
|
||||
let remoteError = creationError;
|
||||
if (session) {
|
||||
try {
|
||||
await this.closeEventSession(session.session_id, options.accessToken);
|
||||
} catch (error) {
|
||||
remoteError = error;
|
||||
}
|
||||
}
|
||||
|
||||
let rotationError: unknown = null;
|
||||
try {
|
||||
await this.rotateEventSession(workspaceId);
|
||||
} catch (error) {
|
||||
rotationError = error;
|
||||
}
|
||||
return { remoteError, rotationError };
|
||||
}));
|
||||
const failed = closeResults.filter(({ remoteError, rotationError }) => (
|
||||
Boolean(rotationError)
|
||||
|| (!options.tolerateRemoteFailure
|
||||
&& Boolean(remoteError)
|
||||
&& !(remoteError instanceof DesignWorkspaceModuleError
|
||||
&& remoteError.code === 'DESIGN_EVENT_SESSION_CLOSED'))
|
||||
)).length;
|
||||
const failedCloses = closeResults.filter((result) => result.status === 'rejected').length;
|
||||
const failed = uncertainCreations + failedCloses;
|
||||
if (failed > 0) {
|
||||
throw new Error(`Failed to close ${failed} AI design Agent Session(s)`);
|
||||
}
|
||||
@@ -1155,7 +1171,11 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
);
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
private async requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
accessToken?: string,
|
||||
): Promise<T> {
|
||||
const response = await this.authorizedFetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
@@ -1165,7 +1185,7 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
: {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}, accessToken);
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
const detail = asErrorDetail(payload);
|
||||
@@ -1184,7 +1204,12 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
private async authorizedFetch(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
accessToken?: string,
|
||||
): Promise<Response> {
|
||||
if (accessToken) return this.fetchWithToken(path, accessToken, init);
|
||||
let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl });
|
||||
if (!token) {
|
||||
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
|
||||
@@ -1314,11 +1339,12 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
for (const resolve of waiters) resolve(run);
|
||||
}
|
||||
|
||||
private async closeEventSession(sessionId: string): Promise<void> {
|
||||
private async closeEventSession(sessionId: string, accessToken?: string): Promise<void> {
|
||||
try {
|
||||
await this.requestJson<ServerAgentSession>(
|
||||
`/api/agents/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'DELETE' },
|
||||
accessToken,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DesignWorkspaceModuleError
|
||||
|
||||
@@ -70,6 +70,15 @@ import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browse
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { createProjectProgressSync } from '../services/project-progress-sync';
|
||||
import { createWorksCloudDeployment } from '../services/works-cloud-deployment';
|
||||
import {
|
||||
consumeWorksSquareStartupRuntimeCleanupRequired,
|
||||
getWorksSquareSessionRestoreStatus,
|
||||
getWorksSquareSessionSnapshot,
|
||||
initializeWorksSquareSession,
|
||||
subscribeWorksSquareSession,
|
||||
type WorksSquareSessionChangeReason,
|
||||
} from '../services/works-square-session';
|
||||
import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime';
|
||||
import { getPort } from '../utils/config';
|
||||
import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets';
|
||||
import {
|
||||
@@ -318,6 +327,26 @@ function registerMakeloreProtocolClient(): void {
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const win = createWindow();
|
||||
const sendAuthSession = (
|
||||
session: ReturnType<typeof getWorksSquareSessionSnapshot>,
|
||||
reason: WorksSquareSessionChangeReason = 'changed',
|
||||
previousSession: ReturnType<typeof getWorksSquareSessionSnapshot> = null,
|
||||
) => {
|
||||
if (!session && getWorksSquareSessionRestoreStatus() === 'unavailable') return;
|
||||
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send('auth:session-changed', session);
|
||||
}
|
||||
if (!session && reason === 'terminal') {
|
||||
void clearManagedWorksSquareRuntimeBestEffort({
|
||||
opencodeManager,
|
||||
imageWorkspace: imageWorkspaceModule ?? undefined,
|
||||
}, 'terminal session invalidation', previousSession?.accessToken);
|
||||
}
|
||||
};
|
||||
const unsubscribeAuthSession = subscribeWorksSquareSession(sendAuthSession);
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
sendAuthSession(getWorksSquareSessionSnapshot());
|
||||
});
|
||||
|
||||
const closeAgentBrowserForHostRenderer = (reason: string): void => {
|
||||
void agentBrowser?.close().catch((error) => {
|
||||
@@ -358,6 +387,7 @@ function createMainWindow(): BrowserWindow {
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
unsubscribeAuthSession();
|
||||
const browser = agentBrowser;
|
||||
agentBrowser = null;
|
||||
void browser?.dispose().catch((error) => {
|
||||
@@ -409,14 +439,15 @@ async function initialize(): Promise<void> {
|
||||
logger.info('Running in E2E mode: startup side effects minimized');
|
||||
}
|
||||
|
||||
// Restore authentication before any background service or renderer can request a token.
|
||||
await initializeWorksSquareSession();
|
||||
|
||||
opencodeProjectStore = createProjectStore(await createElectronProjectStorage());
|
||||
|
||||
if (!isE2EMode) {
|
||||
projectProgressSync = createProjectProgressSync(opencodeProjectStore);
|
||||
void projectProgressSync.start();
|
||||
}
|
||||
worksCloudDeployment = createWorksCloudDeployment(opencodeProjectStore);
|
||||
if (!isE2EMode) void worksCloudDeployment.start();
|
||||
|
||||
const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: app.isPackaged,
|
||||
@@ -432,6 +463,12 @@ async function initialize(): Promise<void> {
|
||||
},
|
||||
});
|
||||
imageWorkspaceModule = imageWorkspace;
|
||||
if (consumeWorksSquareStartupRuntimeCleanupRequired()) {
|
||||
await clearManagedWorksSquareRuntimeBestEffort({
|
||||
opencodeManager,
|
||||
imageWorkspace,
|
||||
}, 'expired persisted session during startup');
|
||||
}
|
||||
if (localImageWorkspaceEnabled) {
|
||||
logger.info('AI painting workspace is using local development storage');
|
||||
} else {
|
||||
@@ -444,6 +481,10 @@ async function initialize(): Promise<void> {
|
||||
// Create the main window
|
||||
const window = createMainWindow();
|
||||
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
|
||||
if (!isE2EMode) {
|
||||
void projectProgressSync?.start();
|
||||
void worksCloudDeployment.start();
|
||||
}
|
||||
|
||||
// Create system tray
|
||||
if (!isE2EMode) {
|
||||
|
||||
@@ -80,6 +80,7 @@ const validEventChannels = [
|
||||
'oauth:error',
|
||||
'agent-browser:show',
|
||||
'agent-browser:state',
|
||||
'auth:session-changed',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
103
electron/services/works-square-runtime.ts
Normal file
103
electron/services/works-square-runtime.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../shared/user-model-config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { clearWorksSquareAIGatewayCredential } from './works-square-ai-gateway';
|
||||
import { getProviderService } from './providers/provider-service';
|
||||
|
||||
export type WorksSquareRuntimeContext = {
|
||||
opencodeManager: {
|
||||
stop(): Promise<unknown>;
|
||||
};
|
||||
imageWorkspace?: {
|
||||
closeEventSessions?(options?: {
|
||||
accessToken?: string;
|
||||
tolerateRemoteFailure?: boolean;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
let cleanupFlight: Promise<void> | null = null;
|
||||
let cleanupRequired = false;
|
||||
|
||||
async function runManagedWorksSquareRuntimeCleanup(
|
||||
ctx: WorksSquareRuntimeContext,
|
||||
accessToken?: string,
|
||||
tolerateRemoteFailure = false,
|
||||
): Promise<void> {
|
||||
// Revoke the in-memory derived credential synchronously; slower resource cleanup follows.
|
||||
clearWorksSquareAIGatewayCredential();
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
(async () => await ctx.imageWorkspace?.closeEventSessions?.({
|
||||
accessToken,
|
||||
tolerateRemoteFailure,
|
||||
}))(),
|
||||
(async () => await ctx.opencodeManager.stop())(),
|
||||
(async () => {
|
||||
const deleted = await getProviderService().deleteAccountApiKey(
|
||||
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
||||
);
|
||||
if (!deleted) throw new Error('provider API key storage rejected deletion');
|
||||
})(),
|
||||
]);
|
||||
const errors = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map((result) => (
|
||||
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
||||
));
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to clear managed Works Square runtime state: ${errors.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearManagedWorksSquareRuntime(
|
||||
ctx: WorksSquareRuntimeContext,
|
||||
accessToken?: string,
|
||||
tolerateRemoteFailure = false,
|
||||
): Promise<void> {
|
||||
if (cleanupFlight) return cleanupFlight;
|
||||
cleanupRequired = true;
|
||||
const flight = runManagedWorksSquareRuntimeCleanup(
|
||||
ctx,
|
||||
accessToken,
|
||||
tolerateRemoteFailure,
|
||||
)
|
||||
.then(() => {
|
||||
cleanupRequired = false;
|
||||
})
|
||||
.finally(() => {
|
||||
if (cleanupFlight === flight) cleanupFlight = null;
|
||||
});
|
||||
cleanupFlight = flight;
|
||||
return flight;
|
||||
}
|
||||
|
||||
export async function ensureManagedWorksSquareRuntimeClean(
|
||||
ctx: WorksSquareRuntimeContext,
|
||||
): Promise<void> {
|
||||
if (cleanupFlight) {
|
||||
try {
|
||||
await cleanupFlight;
|
||||
} catch {
|
||||
// Retry below with the current runtime context.
|
||||
}
|
||||
}
|
||||
if (cleanupRequired) await clearManagedWorksSquareRuntime(ctx);
|
||||
}
|
||||
|
||||
export async function clearManagedWorksSquareRuntimeBestEffort(
|
||||
ctx: WorksSquareRuntimeContext,
|
||||
reason: string,
|
||||
accessToken?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await clearManagedWorksSquareRuntime(ctx, accessToken, true);
|
||||
} catch (error) {
|
||||
logger.error(`[auth] Failed to clear managed runtime after ${reason}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetManagedWorksSquareRuntimeForTests(): void {
|
||||
cleanupFlight = null;
|
||||
cleanupRequired = false;
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { NIANCODE_AUTH_CONFIG } from '../api/auth-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { logger } from '../utils/logger';
|
||||
import { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session';
|
||||
|
||||
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||
const SESSION_STORE_SCHEMA_VERSION = 1;
|
||||
export { WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS } from '../../shared/auth-session';
|
||||
|
||||
export type WorksSquareSessionInput = {
|
||||
accessToken: string;
|
||||
refreshToken?: string | null;
|
||||
tokenType?: string | null;
|
||||
expiresAt?: number | null;
|
||||
lastActiveAt?: number | null;
|
||||
};
|
||||
|
||||
export type WorksSquareTokenPayload = {
|
||||
@@ -19,29 +23,51 @@ export type WorksSquareTokenPayload = {
|
||||
};
|
||||
|
||||
export type WorksSquareSessionSnapshot = {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
expiresAt: number | null;
|
||||
lastActiveAt: number;
|
||||
canRefresh: boolean;
|
||||
};
|
||||
|
||||
type StoredWorksSquareSession = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
tokenType: string;
|
||||
expiresAt: number | null;
|
||||
lastActiveAt: number;
|
||||
};
|
||||
|
||||
export type WorksSquareSessionListener = (session: WorksSquareSessionSnapshot | null) => void;
|
||||
|
||||
let currentSession: WorksSquareSessionSnapshot | null = null;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
const sessionListeners = new Set<WorksSquareSessionListener>();
|
||||
|
||||
function notifySessionListeners(): void {
|
||||
const snapshot = currentSession ? { ...currentSession } : null;
|
||||
for (const listener of sessionListeners) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch {
|
||||
// Session observers must not affect authentication state changes.
|
||||
}
|
||||
}
|
||||
export interface WorksSquareSessionPersistence {
|
||||
discardedInvalidRecord?: boolean;
|
||||
load(): Promise<WorksSquareSessionInput | null>;
|
||||
save(session: WorksSquareSessionInput | null): Promise<void>;
|
||||
}
|
||||
|
||||
export type WorksSquareSessionChangeReason = 'changed' | 'terminal';
|
||||
export type WorksSquareSessionListener = (
|
||||
session: WorksSquareSessionSnapshot | null,
|
||||
reason: WorksSquareSessionChangeReason,
|
||||
previousSession?: WorksSquareSessionSnapshot | null,
|
||||
) => void;
|
||||
export type WorksSquareSessionRestoreStatus = 'ready' | 'unavailable';
|
||||
|
||||
type RefreshFlight = {
|
||||
generation: number;
|
||||
promise: Promise<string | null>;
|
||||
};
|
||||
|
||||
let currentSession: StoredWorksSquareSession | null = null;
|
||||
let credentialGeneration = 0;
|
||||
let refreshFlight: RefreshFlight | null = null;
|
||||
let sessionPersistence: WorksSquareSessionPersistence | null = null;
|
||||
let sessionPersistenceFactory: (() => Promise<WorksSquareSessionPersistence | null>) | null = null;
|
||||
let persistenceQueue: Promise<void> = Promise.resolve();
|
||||
let credentialPersistenceBarrier: Promise<void> | null = null;
|
||||
let restoreStatus: WorksSquareSessionRestoreStatus = 'ready';
|
||||
let startupRuntimeCleanupRequired = false;
|
||||
const sessionListeners = new Set<WorksSquareSessionListener>();
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
@@ -57,11 +83,57 @@ function createBasicAuthHeader(clientId: string, clientSecret: string): string {
|
||||
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
function toPublicSnapshot(
|
||||
session: StoredWorksSquareSession | null,
|
||||
): WorksSquareSessionSnapshot | null {
|
||||
if (!session) return null;
|
||||
return {
|
||||
accessToken: session.accessToken,
|
||||
tokenType: session.tokenType,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
canRefresh: Boolean(session.refreshToken),
|
||||
};
|
||||
}
|
||||
|
||||
function toPersistenceInput(
|
||||
session: StoredWorksSquareSession | null,
|
||||
): WorksSquareSessionInput | null {
|
||||
return session ? { ...session } : null;
|
||||
}
|
||||
|
||||
function normalizeSession(
|
||||
input: WorksSquareSessionInput,
|
||||
nowMs = Date.now(),
|
||||
requireLastActiveAt = false,
|
||||
): StoredWorksSquareSession | null {
|
||||
const accessToken = asString(input.accessToken);
|
||||
if (!accessToken) return null;
|
||||
if (
|
||||
requireLastActiveAt
|
||||
&& (typeof input.lastActiveAt !== 'number' || !Number.isFinite(input.lastActiveAt))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: asString(input.refreshToken),
|
||||
tokenType: asString(input.tokenType) ?? 'Bearer',
|
||||
expiresAt: typeof input.expiresAt === 'number' && Number.isFinite(input.expiresAt)
|
||||
? input.expiresAt
|
||||
: null,
|
||||
lastActiveAt: typeof input.lastActiveAt === 'number' && Number.isFinite(input.lastActiveAt)
|
||||
? Math.min(input.lastActiveAt, nowMs)
|
||||
: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTokenPayload(
|
||||
payload: WorksSquareTokenPayload,
|
||||
fallbackRefreshToken: string | null,
|
||||
nowMs = Date.now(),
|
||||
): WorksSquareSessionSnapshot {
|
||||
lastActiveAt = nowMs,
|
||||
): StoredWorksSquareSession {
|
||||
const accessToken = asString(payload.access_token);
|
||||
if (!accessToken) {
|
||||
throw new Error('Auth refresh response did not include access_token');
|
||||
@@ -72,9 +144,217 @@ function parseTokenPayload(
|
||||
refreshToken: asString(payload.refresh_token) ?? fallbackRefreshToken,
|
||||
tokenType: asString(payload.token_type) ?? 'Bearer',
|
||||
expiresAt: expiresAtFromExpiresIn(payload.expires_in, nowMs),
|
||||
lastActiveAt,
|
||||
};
|
||||
}
|
||||
|
||||
function isSessionIdle(session: StoredWorksSquareSession, nowMs: number): boolean {
|
||||
return nowMs - session.lastActiveAt >= WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function notifySessionListeners(
|
||||
reason: WorksSquareSessionChangeReason = 'changed',
|
||||
previousSession: WorksSquareSessionSnapshot | null = null,
|
||||
): void {
|
||||
const snapshot = toPublicSnapshot(currentSession);
|
||||
for (const listener of sessionListeners) {
|
||||
try {
|
||||
listener(
|
||||
snapshot ? { ...snapshot } : null,
|
||||
reason,
|
||||
previousSession ? { ...previousSession } : null,
|
||||
);
|
||||
} catch {
|
||||
// Session observers must not affect authentication state changes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queuePersistence(session: StoredWorksSquareSession | null): Promise<void> {
|
||||
const persistence = sessionPersistence;
|
||||
if (!persistence) return Promise.resolve();
|
||||
const snapshot = toPersistenceInput(session);
|
||||
const operation = persistenceQueue
|
||||
.catch(() => undefined)
|
||||
.then(() => persistence.save(snapshot));
|
||||
persistenceQueue = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
function replaceCredentialSession(
|
||||
session: StoredWorksSquareSession | null,
|
||||
notify = true,
|
||||
): {
|
||||
generation: number;
|
||||
persisted: Promise<void>;
|
||||
previousSession: WorksSquareSessionSnapshot | null;
|
||||
} {
|
||||
const previousSession = toPublicSnapshot(currentSession);
|
||||
currentSession = session;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
const generation = credentialGeneration;
|
||||
const persisted = queuePersistence(session);
|
||||
if (sessionPersistence) {
|
||||
credentialPersistenceBarrier = persisted;
|
||||
void persisted.then(
|
||||
() => {
|
||||
if (credentialPersistenceBarrier === persisted) credentialPersistenceBarrier = null;
|
||||
},
|
||||
() => {
|
||||
if (credentialPersistenceBarrier === persisted) credentialPersistenceBarrier = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (notify) notifySessionListeners('changed', previousSession);
|
||||
return { generation, persisted, previousSession };
|
||||
}
|
||||
|
||||
async function createElectronSessionPersistence(): Promise<WorksSquareSessionPersistence | null> {
|
||||
const [{ default: Store }, { safeStorage }] = await Promise.all([
|
||||
import('electron-store'),
|
||||
import('electron'),
|
||||
]);
|
||||
const usesUnprotectedLinuxBackend = process.platform === 'linux'
|
||||
&& safeStorage.getSelectedStorageBackend() === 'basic_text';
|
||||
if (!safeStorage.isEncryptionAvailable() || usesUnprotectedLinuxBackend) {
|
||||
logger.warn('[works-square-session] OS credential encryption is unavailable; session restore disabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
type EncryptedSessionRecord = {
|
||||
version: number;
|
||||
authBase: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
const storeOptions = {
|
||||
name: 'works-square-session',
|
||||
configFileMode: 0o600,
|
||||
} as const;
|
||||
let discardedInvalidRecord = false;
|
||||
let store: Store<{ record?: EncryptedSessionRecord }>;
|
||||
try {
|
||||
store = new Store<{ record?: EncryptedSessionRecord }>(storeOptions);
|
||||
} catch (error) {
|
||||
logger.warn('[works-square-session] Invalid session store config; discarding it', error);
|
||||
store = new Store<{ record?: EncryptedSessionRecord }>({
|
||||
...storeOptions,
|
||||
clearInvalidConfig: true,
|
||||
});
|
||||
discardedInvalidRecord = true;
|
||||
}
|
||||
|
||||
return {
|
||||
discardedInvalidRecord,
|
||||
async load() {
|
||||
const record = store.get('record');
|
||||
if (!record) return null;
|
||||
if (
|
||||
record.version !== SESSION_STORE_SCHEMA_VERSION
|
||||
|| record.authBase !== NIANCODE_AUTH_CONFIG.gatewayAuthUrl
|
||||
) {
|
||||
store.delete('record');
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(
|
||||
safeStorage.decryptString(Buffer.from(record.ciphertext, 'base64')),
|
||||
) as WorksSquareSessionInput;
|
||||
},
|
||||
async save(session) {
|
||||
if (!session) {
|
||||
store.delete('record');
|
||||
return;
|
||||
}
|
||||
const encrypted = safeStorage.encryptString(JSON.stringify(session));
|
||||
store.set('record', {
|
||||
version: SESSION_STORE_SCHEMA_VERSION,
|
||||
authBase: NIANCODE_AUTH_CONFIG.gatewayAuthUrl,
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializeWorksSquareSession(
|
||||
options: {
|
||||
persistence?: WorksSquareSessionPersistence;
|
||||
nowMs?: number;
|
||||
} = {},
|
||||
): Promise<WorksSquareSessionSnapshot | null> {
|
||||
persistenceQueue = Promise.resolve();
|
||||
credentialPersistenceBarrier = null;
|
||||
startupRuntimeCleanupRequired = false;
|
||||
sessionPersistenceFactory = options.persistence ? null : createElectronSessionPersistence;
|
||||
try {
|
||||
sessionPersistence = options.persistence
|
||||
?? await sessionPersistenceFactory!();
|
||||
} catch (error) {
|
||||
logger.warn('[works-square-session] Failed to initialize secure session persistence', error);
|
||||
sessionPersistence = null;
|
||||
restoreStatus = 'unavailable';
|
||||
startupRuntimeCleanupRequired = true;
|
||||
currentSession = null;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
return null;
|
||||
}
|
||||
startupRuntimeCleanupRequired = Boolean(sessionPersistence?.discardedInvalidRecord);
|
||||
|
||||
if (!sessionPersistence) {
|
||||
restoreStatus = 'ready';
|
||||
currentSession = null;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
let restored: StoredWorksSquareSession | null;
|
||||
let hadPersistedSession: boolean;
|
||||
try {
|
||||
const persisted = await sessionPersistence.load();
|
||||
hadPersistedSession = Boolean(persisted);
|
||||
restored = persisted
|
||||
? normalizeSession(persisted, options.nowMs ?? Date.now(), true)
|
||||
: null;
|
||||
} catch (error) {
|
||||
logger.warn('[works-square-session] Failed to restore the persisted session', error);
|
||||
restoreStatus = 'unavailable';
|
||||
startupRuntimeCleanupRequired = true;
|
||||
currentSession = null;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
if (restored && !isSessionIdle(restored, nowMs)) {
|
||||
restoreStatus = 'ready';
|
||||
currentSession = restored;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
return toPublicSnapshot(restored);
|
||||
}
|
||||
|
||||
if (!hadPersistedSession) {
|
||||
restoreStatus = 'ready';
|
||||
currentSession = null;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
startupRuntimeCleanupRequired = hadPersistedSession;
|
||||
restoreStatus = 'ready';
|
||||
const cleared = replaceCredentialSession(null, false);
|
||||
try {
|
||||
await cleared.persisted;
|
||||
} catch (error) {
|
||||
restoreStatus = 'unavailable';
|
||||
logger.warn('[works-square-session] Failed to clear an invalid persisted session', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
@@ -86,38 +366,84 @@ async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
}
|
||||
|
||||
export function storeWorksSquareSession(input: WorksSquareSessionInput): void {
|
||||
const accessToken = input.accessToken.trim();
|
||||
if (!accessToken) {
|
||||
const session = normalizeSession(input);
|
||||
if (!session || isSessionIdle(session, Date.now())) {
|
||||
clearWorksSquareSession();
|
||||
return;
|
||||
}
|
||||
replaceCredentialSession(session);
|
||||
}
|
||||
|
||||
currentSession = {
|
||||
accessToken,
|
||||
refreshToken: input.refreshToken?.trim() || null,
|
||||
tokenType: input.tokenType?.trim() || 'Bearer',
|
||||
expiresAt: typeof input.expiresAt === 'number' && Number.isFinite(input.expiresAt)
|
||||
? input.expiresAt
|
||||
: null,
|
||||
};
|
||||
notifySessionListeners();
|
||||
export async function commitWorksSquareSession(
|
||||
input: WorksSquareSessionInput,
|
||||
): Promise<WorksSquareSessionSnapshot | null> {
|
||||
const session = normalizeSession(input);
|
||||
if (!session || isSessionIdle(session, Date.now())) {
|
||||
await clearWorksSquareSessionPersisted('terminal');
|
||||
return null;
|
||||
}
|
||||
const update = replaceCredentialSession(session, false);
|
||||
try {
|
||||
await update.persisted;
|
||||
} catch (error) {
|
||||
await failClosedAfterPersistenceError(update.generation, error);
|
||||
throw error;
|
||||
}
|
||||
restoreStatus = 'ready';
|
||||
if (credentialGeneration !== update.generation) return null;
|
||||
notifySessionListeners('changed', update.previousSession);
|
||||
return getWorksSquareSessionSnapshot();
|
||||
}
|
||||
|
||||
export function storeWorksSquareSessionFromTokenPayload(
|
||||
payload: WorksSquareTokenPayload,
|
||||
fallbackRefreshToken: string | null = null,
|
||||
nowMs = Date.now(),
|
||||
lastActiveAt = nowMs,
|
||||
): WorksSquareSessionSnapshot {
|
||||
const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs);
|
||||
currentSession = session;
|
||||
notifySessionListeners();
|
||||
return session;
|
||||
const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs, lastActiveAt);
|
||||
replaceCredentialSession(session);
|
||||
return toPublicSnapshot(session)!;
|
||||
}
|
||||
|
||||
export async function commitWorksSquareSessionFromTokenPayload(
|
||||
payload: WorksSquareTokenPayload,
|
||||
fallbackRefreshToken: string | null = null,
|
||||
nowMs = Date.now(),
|
||||
lastActiveAt = nowMs,
|
||||
): Promise<WorksSquareSessionSnapshot> {
|
||||
const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs, lastActiveAt);
|
||||
const update = replaceCredentialSession(session, false);
|
||||
try {
|
||||
await update.persisted;
|
||||
} catch (error) {
|
||||
await failClosedAfterPersistenceError(update.generation, error);
|
||||
throw error;
|
||||
}
|
||||
restoreStatus = 'ready';
|
||||
if (credentialGeneration !== update.generation) {
|
||||
throw new Error('Session changed while credentials were being persisted');
|
||||
}
|
||||
notifySessionListeners('changed', update.previousSession);
|
||||
return toPublicSnapshot(session)!;
|
||||
}
|
||||
|
||||
export function clearWorksSquareSession(): void {
|
||||
currentSession = null;
|
||||
refreshPromise = null;
|
||||
notifySessionListeners();
|
||||
replaceCredentialSession(null);
|
||||
}
|
||||
|
||||
async function clearWorksSquareSessionPersisted(
|
||||
reason: WorksSquareSessionChangeReason = 'changed',
|
||||
): Promise<void> {
|
||||
const update = replaceCredentialSession(null, false);
|
||||
if (credentialGeneration === update.generation) {
|
||||
notifySessionListeners(reason, update.previousSession);
|
||||
}
|
||||
await update.persisted;
|
||||
}
|
||||
|
||||
export async function flushWorksSquareSessionPersistence(): Promise<void> {
|
||||
await persistenceQueue;
|
||||
}
|
||||
|
||||
export function subscribeWorksSquareSession(listener: WorksSquareSessionListener): () => void {
|
||||
@@ -128,14 +454,169 @@ export function subscribeWorksSquareSession(listener: WorksSquareSessionListener
|
||||
}
|
||||
|
||||
export function getWorksSquareSessionSnapshot(): WorksSquareSessionSnapshot | null {
|
||||
return currentSession ? { ...currentSession } : null;
|
||||
const snapshot = toPublicSnapshot(currentSession);
|
||||
return snapshot ? { ...snapshot } : null;
|
||||
}
|
||||
|
||||
export function getWorksSquareSessionRestoreStatus(): WorksSquareSessionRestoreStatus {
|
||||
return restoreStatus;
|
||||
}
|
||||
|
||||
export function consumeWorksSquareStartupRuntimeCleanupRequired(): boolean {
|
||||
const required = startupRuntimeCleanupRequired;
|
||||
startupRuntimeCleanupRequired = false;
|
||||
return required;
|
||||
}
|
||||
|
||||
export async function retryWorksSquareSessionRestore(
|
||||
nowMs = Date.now(),
|
||||
): Promise<WorksSquareSessionSnapshot | null> {
|
||||
if (restoreStatus !== 'unavailable') {
|
||||
return getWorksSquareSessionSnapshot();
|
||||
}
|
||||
|
||||
if (!sessionPersistence && sessionPersistenceFactory) {
|
||||
try {
|
||||
sessionPersistence = await sessionPersistenceFactory();
|
||||
startupRuntimeCleanupRequired ||= Boolean(sessionPersistence?.discardedInvalidRecord);
|
||||
} catch (error) {
|
||||
logger.warn('[works-square-session] Secure session persistence is still unavailable', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!sessionPersistence) {
|
||||
restoreStatus = 'ready';
|
||||
return null;
|
||||
}
|
||||
|
||||
let restored: StoredWorksSquareSession | null;
|
||||
let hadPersistedSession: boolean;
|
||||
try {
|
||||
const persisted = await sessionPersistence.load();
|
||||
hadPersistedSession = Boolean(persisted);
|
||||
restored = persisted ? normalizeSession(persisted, nowMs, true) : null;
|
||||
} catch (error) {
|
||||
logger.warn('[works-square-session] Persisted session restore is still unavailable', error);
|
||||
startupRuntimeCleanupRequired = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (restored && !isSessionIdle(restored, nowMs)) {
|
||||
restoreStatus = 'ready';
|
||||
currentSession = restored;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
notifySessionListeners();
|
||||
return getWorksSquareSessionSnapshot();
|
||||
}
|
||||
|
||||
if (!hadPersistedSession) {
|
||||
restoreStatus = 'ready';
|
||||
return null;
|
||||
}
|
||||
|
||||
restoreStatus = 'ready';
|
||||
try {
|
||||
await clearWorksSquareSessionPersisted('terminal');
|
||||
restoreStatus = 'ready';
|
||||
} catch (error) {
|
||||
restoreStatus = 'unavailable';
|
||||
logger.warn('[works-square-session] Failed to clear an unusable persisted session', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function discardUnrestorableWorksSquareSession(): Promise<boolean> {
|
||||
if (restoreStatus !== 'unavailable') return true;
|
||||
const persistence = sessionPersistence;
|
||||
if (!persistence) return false;
|
||||
|
||||
const cleared = replaceCredentialSession(null, false);
|
||||
try {
|
||||
await cleared.persisted;
|
||||
} catch (error) {
|
||||
restoreStatus = 'unavailable';
|
||||
logger.warn('[works-square-session] Failed to discard the unrestorable session', error);
|
||||
return false;
|
||||
}
|
||||
restoreStatus = 'ready';
|
||||
startupRuntimeCleanupRequired = true;
|
||||
if (credentialGeneration === cleared.generation) {
|
||||
notifySessionListeners('terminal', cleared.previousSession);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function markWorksSquareSessionActive(
|
||||
nowMs = Date.now(),
|
||||
): Promise<WorksSquareSessionSnapshot | null> {
|
||||
const session = currentSession;
|
||||
if (!session) return null;
|
||||
if (isSessionIdle(session, nowMs)) {
|
||||
await clearWorksSquareSessionPersisted('terminal');
|
||||
return null;
|
||||
}
|
||||
|
||||
const generation = credentialGeneration;
|
||||
const previousLastActiveAt = session.lastActiveAt;
|
||||
currentSession = { ...session, lastActiveAt: nowMs };
|
||||
try {
|
||||
await queuePersistence(currentSession);
|
||||
} catch (error) {
|
||||
if (
|
||||
credentialGeneration === generation
|
||||
&& currentSession?.lastActiveAt === nowMs
|
||||
) {
|
||||
currentSession = { ...currentSession, lastActiveAt: previousLastActiveAt };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return getWorksSquareSessionSnapshot();
|
||||
}
|
||||
|
||||
async function failClosedAfterPersistenceError(
|
||||
generation: number,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
logger.error('[works-square-session] Failed to persist rotated credentials; clearing session', error);
|
||||
if (credentialGeneration !== generation) return;
|
||||
const cleared = replaceCredentialSession(null, false);
|
||||
if (credentialGeneration === cleared.generation) {
|
||||
notifySessionListeners('terminal', cleared.previousSession);
|
||||
}
|
||||
try {
|
||||
await cleared.persisted;
|
||||
} catch (clearError) {
|
||||
logger.error('[works-square-session] Failed to persist the fail-closed session clear', clearError);
|
||||
}
|
||||
}
|
||||
|
||||
function sessionStillMatches(
|
||||
session: StoredWorksSquareSession,
|
||||
generation: number,
|
||||
): boolean {
|
||||
return credentialGeneration === generation
|
||||
&& currentSession?.accessToken === session.accessToken
|
||||
&& currentSession.refreshToken === session.refreshToken;
|
||||
}
|
||||
|
||||
async function waitForCredentialPersistence(): Promise<boolean> {
|
||||
const barrier = credentialPersistenceBarrier;
|
||||
if (!barrier) return true;
|
||||
try {
|
||||
await barrier;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorksSquareSession(
|
||||
session: StoredWorksSquareSession,
|
||||
generation: number,
|
||||
options: { fetchImpl?: typeof fetch; nowMs?: number } = {},
|
||||
): Promise<string | null> {
|
||||
const session = currentSession;
|
||||
if (!session?.refreshToken) return null;
|
||||
if (!session.refreshToken) return null;
|
||||
|
||||
const fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
@@ -157,41 +638,95 @@ async function refreshWorksSquareSession(
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('[works-square-session] Refresh failed', { status: response.status });
|
||||
clearWorksSquareSession();
|
||||
if ((response.status === 400 || response.status === 401) && sessionStillMatches(session, generation)) {
|
||||
await clearWorksSquareSessionPersisted('terminal');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
clearWorksSquareSession();
|
||||
logger.warn('[works-square-session] Refresh returned an invalid payload');
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextSession = storeWorksSquareSessionFromTokenPayload(
|
||||
if (!sessionStillMatches(session, generation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextSession = parseTokenPayload(
|
||||
payload as WorksSquareTokenPayload,
|
||||
session.refreshToken,
|
||||
nowMs,
|
||||
currentSession!.lastActiveAt,
|
||||
);
|
||||
return nextSession.accessToken;
|
||||
const update = replaceCredentialSession(nextSession, false);
|
||||
try {
|
||||
await update.persisted;
|
||||
} catch (error) {
|
||||
await failClosedAfterPersistenceError(update.generation, error);
|
||||
return null;
|
||||
}
|
||||
if (credentialGeneration !== update.generation) return null;
|
||||
notifySessionListeners('changed', update.previousSession);
|
||||
return currentSession?.accessToken ?? null;
|
||||
}
|
||||
|
||||
export async function getValidWorksSquareAccessToken(
|
||||
options: { fetchImpl?: typeof fetch; nowMs?: number; forceRefresh?: boolean } = {},
|
||||
options: {
|
||||
fetchImpl?: typeof fetch;
|
||||
nowMs?: number;
|
||||
forceRefresh?: boolean;
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
if (restoreStatus === 'unavailable') {
|
||||
await retryWorksSquareSessionRestore(options.nowMs ?? Date.now());
|
||||
if (restoreStatus === 'unavailable') return null;
|
||||
}
|
||||
if (credentialPersistenceBarrier && !await waitForCredentialPersistence()) return null;
|
||||
const session = currentSession;
|
||||
if (!session) return null;
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
if (isSessionIdle(session, nowMs)) {
|
||||
await clearWorksSquareSessionPersisted('terminal');
|
||||
return null;
|
||||
}
|
||||
const nearExpiry = Boolean(
|
||||
session.expiresAt
|
||||
&& session.expiresAt <= nowMs + TOKEN_REFRESH_SKEW_MS,
|
||||
);
|
||||
if (!nearExpiry && !options.forceRefresh) return session.accessToken;
|
||||
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshWorksSquareSession(options).finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
if (!session.refreshToken) {
|
||||
await clearWorksSquareSessionPersisted('terminal');
|
||||
return null;
|
||||
}
|
||||
return await refreshPromise;
|
||||
|
||||
const generation = credentialGeneration;
|
||||
if (refreshFlight?.generation === generation) {
|
||||
return await refreshFlight.promise;
|
||||
}
|
||||
|
||||
const flight: RefreshFlight = {
|
||||
generation,
|
||||
promise: Promise.resolve(null),
|
||||
};
|
||||
flight.promise = refreshWorksSquareSession(session, generation, options).finally(() => {
|
||||
if (refreshFlight === flight) refreshFlight = null;
|
||||
});
|
||||
refreshFlight = flight;
|
||||
return await flight.promise;
|
||||
}
|
||||
|
||||
export function resetWorksSquareSessionForTests(): void {
|
||||
currentSession = null;
|
||||
credentialGeneration += 1;
|
||||
refreshFlight = null;
|
||||
sessionPersistence = null;
|
||||
sessionPersistenceFactory = null;
|
||||
persistenceQueue = Promise.resolve();
|
||||
credentialPersistenceBarrier = null;
|
||||
restoreStatus = 'ready';
|
||||
startupRuntimeCleanupRequired = false;
|
||||
sessionListeners.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user