diff --git a/docs/technical/README.md b/docs/technical/README.md index a3c56e3d..3ae257d1 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -10,6 +10,7 @@ - [SPACETIME_DEV_URI_HOTFIX_2026-04-20.md](./SPACETIME_DEV_URI_HOTFIX_2026-04-20.md):修复开发默认配置把 Spacetime 连接误指向 Vite `3000` 端口的问题。 - [SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md](./SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md):本地 token 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。 - [STDB_AUTH_TAIL_PHASE1_AUTO_GUEST_CREDENTIAL_REMOVAL_2026-04-20.md](./STDB_AUTH_TAIL_PHASE1_AUTO_GUEST_CREDENTIAL_REMOVAL_2026-04-20.md):Auth 尾巴清理第一段,删除前端自动游客用户名/密码残留。 +- [STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md](./STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md):将 STDB token 与旧 HTTP Bearer token 拆成独立存储槽。 - [TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md](./TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md):任务完成后按文件边界自动提交的脚本与协作约定。 - [NODE_DEV_STARTUP_HOTFIX_2026-04-20.md](./NODE_DEV_STARTUP_HOTFIX_2026-04-20.md):`npm run dev` 启动失败的热修记录、根因与验证结果。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 diff --git a/docs/technical/STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md b/docs/technical/STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md new file mode 100644 index 00000000..884232c0 --- /dev/null +++ b/docs/technical/STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md @@ -0,0 +1,73 @@ +# STDB Auth 尾巴清理 Phase 2:拆分 STDB Token 与旧 HTTP Bearer 存储槽(2026-04-20) + +## 1. 本轮目标 + +在不打断现有 `/api/runtime/story/*` 旧链路的前提下,把两种不同语义的 token 从同一个 localStorage key 中拆开: + +1. Spacetime 连接 token +2. 旧 Express Bearer access token + +## 2. 背景问题 + +此前前端把这两种 token 混存到同一个 key: + +- `genarrative.auth.access-token.v1` + +这会带来两个直接问题: + +1. `AuthGate` 和 `authService` 无法区分“当前是在恢复 STDB 会话”还是“当前只是旧 HTTP Bearer 还在” +2. 后续删除 `/api/auth/refresh` 和 `/api/runtime/story/*` 时,很容易互相打断 + +## 3. 本轮落地 + +### 3.1 新的存储语义 + +- HTTP Bearer: + - `genarrative.auth.http-access-token.v1` +- Spacetime token: + - `genarrative.auth.spacetime-token.v1` + +### 3.2 代码调整 + +1. `src/services/apiClient.ts` + - 保留旧 HTTP token API: + - `getStoredAccessToken` + - `setStoredAccessToken` + - `clearStoredAccessToken` + - 新增 STDB token API: + - `getStoredSpacetimeToken` + - `setStoredSpacetimeToken` + - `clearStoredSpacetimeToken` +2. `src/spacetime/client.ts` + - Spacetime 连接改为只读写 STDB token key +3. `src/services/authService.ts` + - 账号恢复链只依据 STDB token 判断是否尝试恢复连接 +4. `src/components/auth/AuthGate.tsx` + - UI 启动时只依据 STDB token 判断是否走会话恢复 + +### 3.3 当前保留不变的旧链路 + +1. `fetchWithApiAuth()` +2. `/api/auth/refresh` +3. `/api/runtime/story/*` 的旧 Bearer 注入 + +这些内容仍继续只使用 HTTP token key。 + +## 4. 当前阶段意义 + +这一步不是最终删除旧 JWT,而是先把 STDB 会话和旧 Express 会话拆成两个独立层: + +1. STDB auth 可以继续独立演进 +2. runtime story 旧链路暂时还能工作 +3. 后续迁 story 到 STDB 时,可以单独移除 HTTP token 相关逻辑 + +## 5. 验证 + +已补定向测试覆盖: + +1. `src/services/apiClient.test.ts` + - 验证 HTTP token 与 STDB token 独立存储 +2. `src/services/authService.test.ts` + - 验证 STDB token 失效回退匿名连接 +3. `src/components/auth/AuthGate.test.tsx` + - 验证 UI 恢复链改为读取 STDB token diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 721fb488..f595677b 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -9,7 +9,7 @@ import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ - getStoredAccessToken: vi.fn(), + getStoredSpacetimeToken: vi.fn(), ensureAutoAuthUser: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), @@ -21,7 +21,7 @@ const authMocks = vi.hoisted(() => ({ vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', - getStoredAccessToken: authMocks.getStoredAccessToken, + getStoredSpacetimeToken: authMocks.getStoredSpacetimeToken, })); vi.mock('../../services/authService', () => ({ @@ -89,7 +89,7 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); - authMocks.getStoredAccessToken.mockReturnValue(null); + authMocks.getStoredSpacetimeToken.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.getCurrentAuthUser.mockReset(); authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); @@ -136,7 +136,7 @@ test('auth gate keeps platform content visible when phone login is available', a }); test('auth gate renders bind phone screen for pending bind users', async () => { - authMocks.getStoredAccessToken.mockReturnValue('token'); + authMocks.getStoredSpacetimeToken.mockReturnValue('token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: { ...mockUser, @@ -156,7 +156,7 @@ test('auth gate renders bind phone screen for pending bind users', async () => { }); test('auth gate shows recovery notice after token fallback', async () => { - authMocks.getStoredAccessToken.mockReturnValue('token'); + authMocks.getStoredSpacetimeToken.mockReturnValue('token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: mockUser, availableLoginMethods: ['phone'], diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 751b1383..0c124547 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -10,7 +10,7 @@ import { import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, - getStoredAccessToken, + getStoredSpacetimeToken, } from '../../services/apiClient'; import { type AuthAuditLogEntry, @@ -253,7 +253,7 @@ export function AuthGate({ children }: AuthGateProps) { setShowLoginModal(true); } - const token = getStoredAccessToken(); + const token = getStoredSpacetimeToken(); if (!token) { await resolveGuestFallback(); return; diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index cfd465bf..6ce8a642 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -3,10 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, clearStoredAccessToken, + clearStoredSpacetimeToken, fetchWithApiAuth, getStoredAccessToken, + getStoredSpacetimeToken, requestJson, setStoredAccessToken, + setStoredSpacetimeToken, } from './apiClient'; function createMemoryStorage() { @@ -63,6 +66,7 @@ describe('apiClient', () => { }); fetchMock.mockReset(); clearStoredAccessToken(); + clearStoredSpacetimeToken(); }); it('attaches auth headers and clears stale tokens on unauthorized responses', async () => { @@ -157,6 +161,19 @@ describe('apiClient', () => { ); }); + it('stores spacetime tokens independently from http access tokens', () => { + setStoredAccessToken('http-token', { emit: false }); + setStoredSpacetimeToken('stdb-token', { emit: false }); + + expect(getStoredAccessToken()).toBe('http-token'); + expect(getStoredSpacetimeToken()).toBe('stdb-token'); + + clearStoredSpacetimeToken({ emit: false }); + + expect(getStoredAccessToken()).toBe('http-token'); + expect(getStoredSpacetimeToken()).toBe(''); + }); + it('retries transient get requests before unwrapping the response envelope', async () => { fetchMock .mockRejectedValueOnce(new TypeError('network unavailable')) diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index f739c682..87539298 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -8,7 +8,8 @@ import { unwrapApiResponse, } from '../../packages/shared/src/http'; -const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1'; +const HTTP_ACCESS_TOKEN_KEY = 'genarrative.auth.http-access-token.v1'; +const SPACETIME_TOKEN_KEY = 'genarrative.auth.spacetime-token.v1'; export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed'; const REQUEST_ID_HEADER = 'x-request-id'; const API_VERSION_HEADER = 'x-api-version'; @@ -330,15 +331,16 @@ function emitAuthStateChange() { } } -export function getStoredAccessToken() { +function readStoredToken(storageKey: string) { if (!canUseLocalStorage()) { return ''; } - return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; + return window.localStorage.getItem(storageKey)?.trim() || ''; } -export function setStoredAccessToken( +function writeStoredToken( + storageKey: string, token: string, options: { emit?: boolean; @@ -350,16 +352,17 @@ export function setStoredAccessToken( const nextToken = token.trim(); if (nextToken) { - window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); + window.localStorage.setItem(storageKey, nextToken); } else { - window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(storageKey); } if (options.emit !== false) { emitAuthStateChange(); } } -export function clearStoredAccessToken( +function removeStoredToken( + storageKey: string, options: { emit?: boolean; } = {}, @@ -368,12 +371,54 @@ export function clearStoredAccessToken( return; } - window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(storageKey); if (options.emit !== false) { emitAuthStateChange(); } } +export function getStoredAccessToken() { + return readStoredToken(HTTP_ACCESS_TOKEN_KEY); +} + +export function setStoredAccessToken( + token: string, + options: { + emit?: boolean; + } = {}, +) { + writeStoredToken(HTTP_ACCESS_TOKEN_KEY, token, options); +} + +export function clearStoredAccessToken( + options: { + emit?: boolean; + } = {}, +) { + removeStoredToken(HTTP_ACCESS_TOKEN_KEY, options); +} + +export function getStoredSpacetimeToken() { + return readStoredToken(SPACETIME_TOKEN_KEY); +} + +export function setStoredSpacetimeToken( + token: string, + options: { + emit?: boolean; + } = {}, +) { + writeStoredToken(SPACETIME_TOKEN_KEY, token, options); +} + +export function clearStoredSpacetimeToken( + options: { + emit?: boolean; + } = {}, +) { + removeStoredToken(SPACETIME_TOKEN_KEY, options); +} + function withAuthorizationHeaders( headers?: HeadersInit, options: Pick = {}, diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 3036ef57..8fd2f62e 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, - clearStoredAccessToken, - setStoredAccessToken, + clearStoredSpacetimeToken, + setStoredSpacetimeToken, } from './apiClient'; import { getAuthRiskBlocks, @@ -182,7 +182,7 @@ describe('authService with SpacetimeDB', () => { spacetimeMocks.ensureSpacetimeConnection.mockReset(); spacetimeMocks.disconnectSpacetimeConnection.mockReset(); - clearStoredAccessToken(); + clearStoredSpacetimeToken(); }); it('extracts captcha challenge details from api errors', () => { @@ -240,7 +240,7 @@ describe('authService with SpacetimeDB', () => { it('falls back to anonymous auth when stored token connection stalls', async () => { vi.useFakeTimers(); - setStoredAccessToken('expired-token', { emit: false }); + setStoredSpacetimeToken('expired-token', { emit: false }); const stalledConnection = new Promise(() => {}); spacetimeMocks.ensureSpacetimeConnection @@ -268,7 +268,7 @@ describe('authService with SpacetimeDB', () => { message: '登录已过期,已切换为匿名账号。', }); expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalled(); - expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBeNull(); + expect(window.localStorage.getItem('genarrative.auth.spacetime-token.v1')).toBeNull(); } finally { vi.useRealTimers(); } diff --git a/src/services/authService.ts b/src/services/authService.ts index cf35f5ed..38c2253e 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -33,8 +33,8 @@ import { } from '../spacetime/mappers'; import { ApiClientError, - clearStoredAccessToken, - getStoredAccessToken, + clearStoredSpacetimeToken, + getStoredSpacetimeToken, } from './apiClient'; export type { AuthUser } from '../../packages/shared/src/contracts/auth'; @@ -150,7 +150,7 @@ async function readCurrentSessionWithConnectionTimeout(timeoutMs: number | null) } async function readCurrentSessionWithRetry() { - const hasStoredToken = Boolean(getStoredAccessToken()); + const hasStoredToken = Boolean(getStoredSpacetimeToken()); try { const session = await readCurrentSessionWithConnectionTimeout( @@ -166,7 +166,7 @@ async function readCurrentSessionWithRetry() { } disconnectSpacetimeConnection(); - clearStoredAccessToken({ emit: false }); + clearStoredSpacetimeToken({ emit: false }); const session = await readCurrentSessionFromConnection(); return { ...session, @@ -231,7 +231,7 @@ export function getCaptchaChallengeFromError( export function clearAuthSession() { disconnectSpacetimeConnection(); - clearStoredAccessToken(); + clearStoredSpacetimeToken(); } export async function sendPhoneLoginCode( diff --git a/src/spacetime/client.ts b/src/spacetime/client.ts index 69faa097..f573b8ff 100644 --- a/src/spacetime/client.ts +++ b/src/spacetime/client.ts @@ -1,6 +1,11 @@ import type { Identity } from 'spacetimedb'; -import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient'; +import { + AUTH_STATE_EVENT, + clearStoredSpacetimeToken, + getStoredSpacetimeToken, + setStoredSpacetimeToken, +} from '../services/apiClient'; import { DbConnection } from './generated'; const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com'; @@ -172,7 +177,7 @@ export function disconnectSpacetimeConnection(options: { clearToken?: boolean } resetReadyState(); currentConnection?.disconnect(); if (options.clearToken) { - clearStoredAccessToken({ emit: false }); + clearStoredSpacetimeToken({ emit: false }); } } @@ -181,10 +186,10 @@ export function buildSpacetimeConnection() { .withUri(resolveSpacetimeUri()) .withDatabaseName(resolveDatabaseName()) .withLightMode(true) - .withToken(getStoredAccessToken() || undefined) + .withToken(getStoredSpacetimeToken() || undefined) .onConnect((nextConnection, _identity, token) => { activeConnection = nextConnection; - setStoredAccessToken(token, { emit: false }); + setStoredSpacetimeToken(token, { emit: false }); installConnectionCallbacks(nextConnection); if (hasActiveSubscription) { resolveReady?.(nextConnection);