拆分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_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 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。 - [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_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):任务完成后按文件边界自动提交的脚本与协作约定。 - [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_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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [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'; import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({ const authMocks = vi.hoisted(() => ({
getStoredAccessToken: vi.fn(), getStoredSpacetimeToken: vi.fn(),
ensureAutoAuthUser: vi.fn(), ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(), getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(), getCurrentAuthUser: vi.fn(),
@@ -21,7 +21,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({ vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed', AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
getStoredAccessToken: authMocks.getStoredAccessToken, getStoredSpacetimeToken: authMocks.getStoredSpacetimeToken,
})); }));
vi.mock('../../services/authService', () => ({ vi.mock('../../services/authService', () => ({
@@ -89,7 +89,7 @@ const mockUser: AuthUser = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
authMocks.getStoredAccessToken.mockReturnValue(null); authMocks.getStoredSpacetimeToken.mockReturnValue(null);
authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.getCurrentAuthUser.mockReset(); authMocks.getCurrentAuthUser.mockReset();
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); 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 () => { test('auth gate renders bind phone screen for pending bind users', async () => {
authMocks.getStoredAccessToken.mockReturnValue('token'); authMocks.getStoredSpacetimeToken.mockReturnValue('token');
authMocks.getCurrentAuthUser.mockResolvedValue({ authMocks.getCurrentAuthUser.mockResolvedValue({
user: { user: {
...mockUser, ...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 () => { test('auth gate shows recovery notice after token fallback', async () => {
authMocks.getStoredAccessToken.mockReturnValue('token'); authMocks.getStoredSpacetimeToken.mockReturnValue('token');
authMocks.getCurrentAuthUser.mockResolvedValue({ authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser, user: mockUser,
availableLoginMethods: ['phone'], availableLoginMethods: ['phone'],

View File

@@ -10,7 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings'; import { useGameSettings } from '../../hooks/useGameSettings';
import { import {
AUTH_STATE_EVENT, AUTH_STATE_EVENT,
getStoredAccessToken, getStoredSpacetimeToken,
} from '../../services/apiClient'; } from '../../services/apiClient';
import { import {
type AuthAuditLogEntry, type AuthAuditLogEntry,
@@ -253,7 +253,7 @@ export function AuthGate({ children }: AuthGateProps) {
setShowLoginModal(true); setShowLoginModal(true);
} }
const token = getStoredAccessToken(); const token = getStoredSpacetimeToken();
if (!token) { if (!token) {
await resolveGuestFallback(); await resolveGuestFallback();
return; return;

View File

@@ -3,10 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
ApiClientError, ApiClientError,
clearStoredAccessToken, clearStoredAccessToken,
clearStoredSpacetimeToken,
fetchWithApiAuth, fetchWithApiAuth,
getStoredAccessToken, getStoredAccessToken,
getStoredSpacetimeToken,
requestJson, requestJson,
setStoredAccessToken, setStoredAccessToken,
setStoredSpacetimeToken,
} from './apiClient'; } from './apiClient';
function createMemoryStorage() { function createMemoryStorage() {
@@ -63,6 +66,7 @@ describe('apiClient', () => {
}); });
fetchMock.mockReset(); fetchMock.mockReset();
clearStoredAccessToken(); clearStoredAccessToken();
clearStoredSpacetimeToken();
}); });
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => { 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 () => { it('retries transient get requests before unwrapping the response envelope', async () => {
fetchMock fetchMock
.mockRejectedValueOnce(new TypeError('network unavailable')) .mockRejectedValueOnce(new TypeError('network unavailable'))

View File

@@ -8,7 +8,8 @@ import {
unwrapApiResponse, unwrapApiResponse,
} from '../../packages/shared/src/http'; } 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'; export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id'; const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version'; const API_VERSION_HEADER = 'x-api-version';
@@ -330,15 +331,16 @@ function emitAuthStateChange() {
} }
} }
export function getStoredAccessToken() { function readStoredToken(storageKey: string) {
if (!canUseLocalStorage()) { if (!canUseLocalStorage()) {
return ''; return '';
} }
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || ''; return window.localStorage.getItem(storageKey)?.trim() || '';
} }
export function setStoredAccessToken( function writeStoredToken(
storageKey: string,
token: string, token: string,
options: { options: {
emit?: boolean; emit?: boolean;
@@ -350,16 +352,17 @@ export function setStoredAccessToken(
const nextToken = token.trim(); const nextToken = token.trim();
if (nextToken) { if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken); window.localStorage.setItem(storageKey, nextToken);
} else { } else {
window.localStorage.removeItem(ACCESS_TOKEN_KEY); window.localStorage.removeItem(storageKey);
} }
if (options.emit !== false) { if (options.emit !== false) {
emitAuthStateChange(); emitAuthStateChange();
} }
} }
export function clearStoredAccessToken( function removeStoredToken(
storageKey: string,
options: { options: {
emit?: boolean; emit?: boolean;
} = {}, } = {},
@@ -368,12 +371,54 @@ export function clearStoredAccessToken(
return; return;
} }
window.localStorage.removeItem(ACCESS_TOKEN_KEY); window.localStorage.removeItem(storageKey);
if (options.emit !== false) { if (options.emit !== false) {
emitAuthStateChange(); 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( function withAuthorizationHeaders(
headers?: HeadersInit, headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {}, options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},

View File

@@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
ApiClientError, ApiClientError,
clearStoredAccessToken, clearStoredSpacetimeToken,
setStoredAccessToken, setStoredSpacetimeToken,
} from './apiClient'; } from './apiClient';
import { import {
getAuthRiskBlocks, getAuthRiskBlocks,
@@ -182,7 +182,7 @@ describe('authService with SpacetimeDB', () => {
spacetimeMocks.ensureSpacetimeConnection.mockReset(); spacetimeMocks.ensureSpacetimeConnection.mockReset();
spacetimeMocks.disconnectSpacetimeConnection.mockReset(); spacetimeMocks.disconnectSpacetimeConnection.mockReset();
clearStoredAccessToken(); clearStoredSpacetimeToken();
}); });
it('extracts captcha challenge details from api errors', () => { 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 () => { it('falls back to anonymous auth when stored token connection stalls', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
setStoredAccessToken('expired-token', { emit: false }); setStoredSpacetimeToken('expired-token', { emit: false });
const stalledConnection = new Promise<never>(() => {}); const stalledConnection = new Promise<never>(() => {});
spacetimeMocks.ensureSpacetimeConnection spacetimeMocks.ensureSpacetimeConnection
@@ -268,7 +268,7 @@ describe('authService with SpacetimeDB', () => {
message: '登录已过期,已切换为匿名账号。', message: '登录已过期,已切换为匿名账号。',
}); });
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalled(); 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 { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }

View File

@@ -33,8 +33,8 @@ import {
} from '../spacetime/mappers'; } from '../spacetime/mappers';
import { import {
ApiClientError, ApiClientError,
clearStoredAccessToken, clearStoredSpacetimeToken,
getStoredAccessToken, getStoredSpacetimeToken,
} from './apiClient'; } from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth'; export type { AuthUser } from '../../packages/shared/src/contracts/auth';
@@ -150,7 +150,7 @@ async function readCurrentSessionWithConnectionTimeout(timeoutMs: number | null)
} }
async function readCurrentSessionWithRetry() { async function readCurrentSessionWithRetry() {
const hasStoredToken = Boolean(getStoredAccessToken()); const hasStoredToken = Boolean(getStoredSpacetimeToken());
try { try {
const session = await readCurrentSessionWithConnectionTimeout( const session = await readCurrentSessionWithConnectionTimeout(
@@ -166,7 +166,7 @@ async function readCurrentSessionWithRetry() {
} }
disconnectSpacetimeConnection(); disconnectSpacetimeConnection();
clearStoredAccessToken({ emit: false }); clearStoredSpacetimeToken({ emit: false });
const session = await readCurrentSessionFromConnection(); const session = await readCurrentSessionFromConnection();
return { return {
...session, ...session,
@@ -231,7 +231,7 @@ export function getCaptchaChallengeFromError(
export function clearAuthSession() { export function clearAuthSession() {
disconnectSpacetimeConnection(); disconnectSpacetimeConnection();
clearStoredAccessToken(); clearStoredSpacetimeToken();
} }
export async function sendPhoneLoginCode( export async function sendPhoneLoginCode(

View File

@@ -1,6 +1,11 @@
import type { Identity } from 'spacetimedb'; 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'; import { DbConnection } from './generated';
const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com'; const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com';
@@ -172,7 +177,7 @@ export function disconnectSpacetimeConnection(options: { clearToken?: boolean }
resetReadyState(); resetReadyState();
currentConnection?.disconnect(); currentConnection?.disconnect();
if (options.clearToken) { if (options.clearToken) {
clearStoredAccessToken({ emit: false }); clearStoredSpacetimeToken({ emit: false });
} }
} }
@@ -181,10 +186,10 @@ export function buildSpacetimeConnection() {
.withUri(resolveSpacetimeUri()) .withUri(resolveSpacetimeUri())
.withDatabaseName(resolveDatabaseName()) .withDatabaseName(resolveDatabaseName())
.withLightMode(true) .withLightMode(true)
.withToken(getStoredAccessToken() || undefined) .withToken(getStoredSpacetimeToken() || undefined)
.onConnect((nextConnection, _identity, token) => { .onConnect((nextConnection, _identity, token) => {
activeConnection = nextConnection; activeConnection = nextConnection;
setStoredAccessToken(token, { emit: false }); setStoredSpacetimeToken(token, { emit: false });
installConnectionCallbacks(nextConnection); installConnectionCallbacks(nextConnection);
if (hasActiveSubscription) { if (hasActiveSubscription) {
resolveReady?.(nextConnection); resolveReady?.(nextConnection);