拆分STDB与HTTP会话token存储槽

This commit is contained in:
2026-04-20 09:32:21 +00:00
parent 9f225684b5
commit 06a8853167
9 changed files with 170 additions and 29 deletions

View File

@@ -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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。

View File

@@ -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

View File

@@ -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'],

View File

@@ -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;

View File

@@ -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'))

View File

@@ -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<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},

View File

@@ -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<never>(() => {});
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();
}

View File

@@ -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(

View File

@@ -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);