2449e77461
## 变更 - 新增按服务 origin、平台 userId / Developer Key 摘要和本地 projectId 分区的 External Editor 项目绑定 - 新增按当前 principal、远端项目、本地 assetId、源 SHA-256、媒体类型和 canonical kind 分区的资源绑定 - manifest 中的 canvasProjectId / resourceId / assetObjectId 仅保留来源信息,不再作为当前账号的可编辑授权 - 切换账号后从本地正式资源重新上传、confirm、登记;图片、视频、角色动画、素材画布参考、art-spec 派生和 Direct 恢复统一使用当前账号绑定 - prepared / accepted / running 账本继续冻结原 principal;账号变化或远端结果不确定时停止补偿并保留现场等待对账 - 同步技术方案、decision log 和 pitfalls ## Review 结论 - 两路独立代码 review 均未发现剩余 P0-P2 - Review 发现并关闭了 max-pass 测试误放宽问题,恢复为绑定最大轮次的强断言 - 首轮 CI 暴露两处本 PR import 排序错误,已在独立提交09486f142中修复并复核 ## 验证 - Rust 完整测试:2301 passed,0 failed,16 ignored - Rust 集成与构建测试:5 + 2 + 14 passed - repository-ci 本地同构门禁通过:lint、typecheck、139 表 SpacetimeDB schema guard、403 个 appSurface 测试、web/admin-web build - External Editor procedure 真实 smoke 通过:精确重放、冲突、删除 fail-close、并发和孤儿检查 - Encoding check:5594 files - git diff --check 通过 - Gitea Project CI run 1253:Repository checks、Frontend、Backend、Native shell tests 全部通过 - 当前 heada0b8415be已合并 origin/master 44ee28c43,PR 无冲突 ## 后续依赖 PR #176 暴露了这一公共账号身份缺陷。该 PR 合并后,#176 需要 rebase,并删除或接入其局部 canonical cache,不能保留第二套账号绑定系统。 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/182 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
|
import { resolveTauriInvoke } from '../app/tauri';
|
|
import {
|
|
getCurrentClientAuthUser,
|
|
getStoredAuthAccessToken,
|
|
refreshClientAuthAccessToken,
|
|
} from './clientAuth';
|
|
import { getClientServerBaseUrl } from './clientHttp';
|
|
|
|
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
|
|
|
type CommittedPlatformSession = {
|
|
user: AuthUser;
|
|
accessToken: string;
|
|
apiBaseUrl: string;
|
|
generation: number;
|
|
};
|
|
|
|
export type PlatformSessionRefreshResult =
|
|
| { status: 'refreshed'; user: AuthUser; generation: number }
|
|
| { status: 'stale' }
|
|
| { status: 'failed'; error: unknown };
|
|
|
|
type PlatformSessionRefreshListener = (
|
|
result: PlatformSessionRefreshResult,
|
|
) => void;
|
|
|
|
type PlatformSessionGenerationListener = (generation: number) => void;
|
|
|
|
let platformAuthGeneration = 0;
|
|
let platformNativeGeneration = 0;
|
|
let platformNativeGenerationFloorPromise: Promise<number> | null = null;
|
|
let committedPlatformSession: CommittedPlatformSession | null = null;
|
|
let desiredPlatformSession: CommittedPlatformSession | null = null;
|
|
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
|
|
null;
|
|
let platformSessionNativeMutationTail: Promise<void> = Promise.resolve();
|
|
const platformSessionRefreshListeners =
|
|
new Set<PlatformSessionRefreshListener>();
|
|
const platformSessionGenerationListeners =
|
|
new Set<PlatformSessionGenerationListener>();
|
|
|
|
function restoreCommittedAccessToken() {
|
|
if (committedPlatformSession?.accessToken) {
|
|
window.localStorage.setItem(
|
|
ACCESS_TOKEN_STORAGE_KEY,
|
|
committedPlatformSession.accessToken,
|
|
);
|
|
return;
|
|
}
|
|
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
|
}
|
|
|
|
function restoreCurrentRendererAccessToken() {
|
|
if (!desiredPlatformSession) {
|
|
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
|
return;
|
|
}
|
|
restoreCommittedAccessToken();
|
|
}
|
|
|
|
function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) {
|
|
for (const listener of platformSessionRefreshListeners) {
|
|
listener(result);
|
|
}
|
|
}
|
|
|
|
function notifyPlatformSessionGeneration() {
|
|
for (const listener of platformSessionGenerationListeners) {
|
|
listener(platformAuthGeneration);
|
|
}
|
|
}
|
|
|
|
async function installNativePlatformSession(
|
|
session: CommittedPlatformSession,
|
|
generation: number,
|
|
) {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke) return;
|
|
await invoke('install_platform_account_session', {
|
|
userId: session.user.id,
|
|
accessToken: session.accessToken,
|
|
apiBaseUrl: session.apiBaseUrl,
|
|
generation,
|
|
});
|
|
}
|
|
|
|
async function clearNativePlatformSession(generation: number) {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke) return;
|
|
await invoke('clear_platform_account_session', { generation });
|
|
}
|
|
|
|
function enqueuePlatformSessionNativeMutation<T>(
|
|
operation: () => Promise<T>,
|
|
): Promise<T> {
|
|
const pending = platformSessionNativeMutationTail
|
|
.catch(() => undefined)
|
|
.then(operation);
|
|
platformSessionNativeMutationTail = pending.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return pending;
|
|
}
|
|
|
|
async function readNativePlatformSessionGenerationFloor() {
|
|
const invoke = resolveTauriInvoke();
|
|
if (!invoke) return 0;
|
|
const floor = await invoke<number | null>(
|
|
'read_platform_account_session_generation',
|
|
);
|
|
// Browser/unit-test adapters commonly expose a no-op invoke that returns null
|
|
// for native-only read commands. They have no surviving Rust generation floor.
|
|
if (floor === null) return 0;
|
|
if (!Number.isSafeInteger(floor) || floor < 0) {
|
|
throw new Error('本地运行时登录态 generation 无效,请重启客户端后重试');
|
|
}
|
|
return floor;
|
|
}
|
|
|
|
async function reserveNativePlatformSessionGeneration() {
|
|
platformNativeGenerationFloorPromise ??=
|
|
readNativePlatformSessionGenerationFloor();
|
|
const nativeGenerationFloor = await platformNativeGenerationFloorPromise;
|
|
platformNativeGeneration = Math.max(
|
|
platformNativeGeneration + 1,
|
|
platformAuthGeneration,
|
|
nativeGenerationFloor + 1,
|
|
);
|
|
return platformNativeGeneration;
|
|
}
|
|
|
|
async function reconcileNativePlatformSessionToCurrentAuthority() {
|
|
for (;;) {
|
|
const authoritativeGeneration = platformAuthGeneration;
|
|
const authoritativeSession = desiredPlatformSession
|
|
? { ...desiredPlatformSession }
|
|
: null;
|
|
const reconciliationGeneration =
|
|
await reserveNativePlatformSessionGeneration();
|
|
restoreCurrentRendererAccessToken();
|
|
try {
|
|
if (authoritativeSession) {
|
|
await installNativePlatformSession(
|
|
authoritativeSession,
|
|
reconciliationGeneration,
|
|
);
|
|
} else {
|
|
await clearNativePlatformSession(reconciliationGeneration);
|
|
}
|
|
} catch (error) {
|
|
if (platformAuthGeneration === authoritativeGeneration) {
|
|
committedPlatformSession = null;
|
|
desiredPlatformSession = null;
|
|
restoreCommittedAccessToken();
|
|
notifyPlatformSessionGeneration();
|
|
}
|
|
throw error;
|
|
}
|
|
if (platformAuthGeneration !== authoritativeGeneration) {
|
|
continue;
|
|
}
|
|
committedPlatformSession = authoritativeSession
|
|
? {
|
|
...authoritativeSession,
|
|
generation: authoritativeGeneration,
|
|
}
|
|
: null;
|
|
desiredPlatformSession = committedPlatformSession
|
|
? { ...committedPlatformSession }
|
|
: null;
|
|
restoreCommittedAccessToken();
|
|
notifyPlatformSessionGeneration();
|
|
return;
|
|
}
|
|
}
|
|
|
|
function resolvePlatformApiBaseUrl() {
|
|
return getClientServerBaseUrl();
|
|
}
|
|
|
|
async function commitPlatformSession(
|
|
user: AuthUser,
|
|
accessToken: string,
|
|
apiBaseUrl: string,
|
|
expectedGeneration: number,
|
|
): Promise<CommittedPlatformSession | null> {
|
|
if (platformAuthGeneration !== expectedGeneration) {
|
|
restoreCurrentRendererAccessToken();
|
|
return null;
|
|
}
|
|
const candidate: CommittedPlatformSession = {
|
|
user,
|
|
accessToken,
|
|
apiBaseUrl,
|
|
generation: expectedGeneration + 1,
|
|
};
|
|
// Reserve a new generation so older refresh/login work becomes stale, but keep the previous
|
|
// committed identity authoritative until Rust and Runner have accepted the candidate.
|
|
platformAuthGeneration = candidate.generation;
|
|
desiredPlatformSession = { ...candidate };
|
|
notifyPlatformSessionGeneration();
|
|
restoreCommittedAccessToken();
|
|
const nativeGeneration = await reserveNativePlatformSessionGeneration();
|
|
try {
|
|
await installNativePlatformSession(candidate, nativeGeneration);
|
|
} catch (error) {
|
|
if (platformAuthGeneration === candidate.generation) {
|
|
desiredPlatformSession = committedPlatformSession
|
|
? { ...committedPlatformSession }
|
|
: null;
|
|
}
|
|
await reconcileNativePlatformSessionToCurrentAuthority();
|
|
if (platformAuthGeneration !== candidate.generation) return null;
|
|
throw error;
|
|
}
|
|
if (platformAuthGeneration !== candidate.generation) {
|
|
await reconcileNativePlatformSessionToCurrentAuthority();
|
|
return null;
|
|
}
|
|
committedPlatformSession = candidate;
|
|
desiredPlatformSession = { ...candidate };
|
|
restoreCommittedAccessToken();
|
|
notifyPlatformSessionGeneration();
|
|
return candidate;
|
|
}
|
|
|
|
export function currentPlatformSessionGeneration() {
|
|
return platformAuthGeneration;
|
|
}
|
|
|
|
export function currentPlatformSessionApiBaseUrl() {
|
|
return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl();
|
|
}
|
|
|
|
export function beginPlatformSessionTransition() {
|
|
platformAuthGeneration += 1;
|
|
desiredPlatformSession = committedPlatformSession
|
|
? { ...committedPlatformSession }
|
|
: null;
|
|
notifyPlatformSessionGeneration();
|
|
return platformAuthGeneration;
|
|
}
|
|
|
|
export function beginPlatformSessionClearTransition() {
|
|
platformAuthGeneration += 1;
|
|
desiredPlatformSession = null;
|
|
notifyPlatformSessionGeneration();
|
|
return platformAuthGeneration;
|
|
}
|
|
|
|
export async function commitAuthenticatedPlatformSession(
|
|
user: AuthUser,
|
|
expectedGeneration: number,
|
|
apiBaseUrl = resolvePlatformApiBaseUrl(),
|
|
) {
|
|
const accessToken = getStoredAuthAccessToken();
|
|
if (!accessToken) {
|
|
throw new Error('陶泥儿登录凭据缺失,请重新登录');
|
|
}
|
|
return enqueuePlatformSessionNativeMutation(async () => {
|
|
const session = await commitPlatformSession(
|
|
user,
|
|
accessToken,
|
|
apiBaseUrl,
|
|
expectedGeneration,
|
|
);
|
|
if (!session) return null;
|
|
return session.generation;
|
|
});
|
|
}
|
|
|
|
export async function refreshPlatformSessionForGeneration(
|
|
expectedGeneration: number,
|
|
apiBaseUrl = resolvePlatformApiBaseUrl(),
|
|
) {
|
|
const token = await refreshClientAuthAccessToken(apiBaseUrl);
|
|
if (platformAuthGeneration !== expectedGeneration) {
|
|
restoreCurrentRendererAccessToken();
|
|
return null;
|
|
}
|
|
return token;
|
|
}
|
|
|
|
export function requestPlatformSessionRefresh(expectedUserId?: string) {
|
|
if (platformSessionRefreshPromise) return platformSessionRefreshPromise;
|
|
|
|
const expectedGeneration = platformAuthGeneration;
|
|
const apiBaseUrl =
|
|
committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl();
|
|
const expectedSessionUserId =
|
|
expectedUserId?.trim() || committedPlatformSession?.user.id || '';
|
|
platformSessionRefreshPromise =
|
|
(async (): Promise<PlatformSessionRefreshResult> => {
|
|
try {
|
|
const refreshed = await refreshPlatformSessionForGeneration(
|
|
expectedGeneration,
|
|
apiBaseUrl,
|
|
);
|
|
if (!refreshed) return { status: 'stale' };
|
|
const user = await getCurrentClientAuthUser(apiBaseUrl);
|
|
if (
|
|
platformAuthGeneration !== expectedGeneration ||
|
|
!user ||
|
|
(expectedSessionUserId && user.id !== expectedSessionUserId)
|
|
) {
|
|
restoreCurrentRendererAccessToken();
|
|
return { status: 'stale' };
|
|
}
|
|
const committedGeneration = await commitAuthenticatedPlatformSession(
|
|
user,
|
|
expectedGeneration,
|
|
apiBaseUrl,
|
|
);
|
|
if (committedGeneration === null) return { status: 'stale' };
|
|
return {
|
|
status: 'refreshed',
|
|
user,
|
|
generation: committedGeneration,
|
|
};
|
|
} catch (error) {
|
|
if (platformAuthGeneration !== expectedGeneration) {
|
|
restoreCurrentRendererAccessToken();
|
|
return { status: 'stale' };
|
|
}
|
|
let failure = error;
|
|
const currentOwnerUserId = committedPlatformSession?.user.id || '';
|
|
if (
|
|
currentOwnerUserId &&
|
|
currentOwnerUserId !== expectedSessionUserId
|
|
) {
|
|
restoreCurrentRendererAccessToken();
|
|
return { status: 'stale' };
|
|
}
|
|
if (
|
|
!currentOwnerUserId ||
|
|
currentOwnerUserId === expectedSessionUserId
|
|
) {
|
|
const clearGeneration = beginPlatformSessionClearTransition();
|
|
try {
|
|
await clearCommittedPlatformSession(clearGeneration);
|
|
} catch (clearError) {
|
|
failure = clearError;
|
|
}
|
|
}
|
|
return { status: 'failed', error: failure };
|
|
}
|
|
})().then((result) => {
|
|
notifyPlatformSessionRefresh(result);
|
|
return result;
|
|
});
|
|
platformSessionRefreshPromise.finally(() => {
|
|
platformSessionRefreshPromise = null;
|
|
});
|
|
return platformSessionRefreshPromise;
|
|
}
|
|
|
|
export function subscribePlatformSessionRefresh(
|
|
listener: PlatformSessionRefreshListener,
|
|
) {
|
|
platformSessionRefreshListeners.add(listener);
|
|
return () => {
|
|
platformSessionRefreshListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
export function subscribePlatformSessionGeneration(
|
|
listener: PlatformSessionGenerationListener,
|
|
) {
|
|
platformSessionGenerationListeners.add(listener);
|
|
return () => {
|
|
platformSessionGenerationListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
export async function clearCommittedPlatformSession(generation: number) {
|
|
return enqueuePlatformSessionNativeMutation(async () => {
|
|
if (platformAuthGeneration !== generation) {
|
|
await reconcileNativePlatformSessionToCurrentAuthority();
|
|
return;
|
|
}
|
|
desiredPlatformSession = null;
|
|
const nativeGeneration = await reserveNativePlatformSessionGeneration();
|
|
try {
|
|
await clearNativePlatformSession(nativeGeneration);
|
|
} catch {
|
|
if (platformAuthGeneration === generation) {
|
|
desiredPlatformSession = null;
|
|
}
|
|
await reconcileNativePlatformSessionToCurrentAuthority();
|
|
return;
|
|
}
|
|
if (platformAuthGeneration !== generation) {
|
|
await reconcileNativePlatformSessionToCurrentAuthority();
|
|
return;
|
|
}
|
|
committedPlatformSession = null;
|
|
desiredPlatformSession = null;
|
|
restoreCommittedAccessToken();
|
|
notifyPlatformSessionGeneration();
|
|
});
|
|
}
|
|
|
|
export function resetPlatformSessionStateForTests() {
|
|
platformAuthGeneration = 0;
|
|
platformNativeGeneration = 0;
|
|
platformNativeGenerationFloorPromise = null;
|
|
committedPlatformSession = null;
|
|
desiredPlatformSession = null;
|
|
platformSessionRefreshPromise = null;
|
|
platformSessionNativeMutationTail = Promise.resolve();
|
|
platformSessionRefreshListeners.clear();
|
|
platformSessionGenerationListeners.clear();
|
|
}
|