拆分STDB与HTTP会话token存储槽
This commit is contained in:
@@ -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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
||||
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'> = {},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user