diff --git a/docs/technical/README.md b/docs/technical/README.md index 935c3c30..7a98fac9 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -13,6 +13,7 @@ - [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 拆成独立存储槽。 - [RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md):把 `runtimeStoryService` 改成可替换 transport,为后续 STDB provider 接入预留稳定边界。 - [RUNTIME_STORY_TO_STDB_PHASE2_CONTRACT_DESIGN_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE2_CONTRACT_DESIGN_2026-04-20.md):梳理 runtime story 从 Express 迁到 STDB 所需的聚合 view、procedure、mapper 与前端 provider 设计。 +- [RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md):确认 runtime story 当前 STDB/Express 快照真源分裂,并补一层只改 story 边界的 STDB 兼容桥。 - [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/RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md b/docs/technical/RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md new file mode 100644 index 00000000..75783612 --- /dev/null +++ b/docs/technical/RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md @@ -0,0 +1,136 @@ +# Runtime Story 向 STDB 迁移 Phase 2A:兼容桥(2026-04-20) + +## 1. 本轮目标 + +这轮不是直接把 `runtime story` 全量改成 SpacetimeDB provider,而是先补一层最小兼容桥,解决当前仓库里已经实际出现的两类断裂: + +1. `runtime story` 前置快照写入已经走 STDB +2. `/api/runtime/story/*` 仍然从 `server-node` 的旧 `RuntimeRepository` 读取 Postgres 快照 +3. 前端认证主链路已经切到 STDB,旧 HTTP Bearer token 不再保证可持续刷新 + +因此本轮目标是: + +1. 保持 `runtimeStoryCoordinator` 和上层页面契约不变 +2. 保持 `server-node/src/modules/story/storyActionService.ts` 现有规则实现不重写 +3. 只在 `runtime story` 边界补齐: + - 前端请求可携带 STDB token + - `server-node` 的 story route 可直接使用 STDB token 解析身份 + - `server-node` 的 story route 可直接从 STDB 读写 snapshot + +## 2. 当前问题确认 + +### 2.1 快照真源已经分裂 + +当前前端 [`src/hooks/story/runtimeStoryCoordinator.ts`](/home/Genarrative/src/hooks/story/runtimeStoryCoordinator.ts) 在读取或执行 runtime story 之前,会先调用: + +1. [`putSaveSnapshot`](/home/Genarrative/src/services/storageService.ts) +2. 该调用实际走 STDB `saveSnapshot` procedure + +但旧的 `/api/runtime/story/*` 仍然走: + +1. [`storyActionRoutes.ts`](/home/Genarrative/server-node/src/modules/story/storyActionRoutes.ts) +2. [`storyActionService.ts`](/home/Genarrative/server-node/src/modules/story/storyActionService.ts) +3. [`RuntimeRepository.getSnapshot()`](/home/Genarrative/server-node/src/repositories/runtimeRepository.ts) + +也就是: + +1. 写快照在 STDB +2. 读快照在 Postgres + +这已经不是“未来可能出现”的风险,而是当前迁移阶段的真实断链。 + +### 2.2 旧 HTTP token 不再是可靠前提 + +当前前端认证主链路已经切到: + +1. [`src/services/authService.ts`](/home/Genarrative/src/services/authService.ts) +2. [`src/spacetime/client.ts`](/home/Genarrative/src/spacetime/client.ts) + +也就是说: + +1. 页面登录、恢复会话、匿名建连主要依赖 STDB token +2. 旧 HTTP access token 仅剩兼容用途 +3. `/api/auth/refresh` 也不再保证始终存在有效 refresh cookie + +因此继续假设 `/api/runtime/story/*` 一定能依赖旧 JWT Bearer,是不稳的。 + +## 3. 本轮兼容桥方案 + +### 3.1 前端 + +[`runtimeStoryService.ts`](/home/Genarrative/src/services/runtimeStoryService.ts) 继续保留 HTTP transport,但在请求 `/api/runtime/story/*` 时: + +1. 优先携带现有 HTTP access token +2. 若不存在 HTTP access token,则回退携带 STDB token +3. 新增专用 header,明确这是一条 runtime story 的 STDB 兼容认证链路 + +这样上层调用不变,但 `server-node` 侧已经能识别 STDB token。 + +### 3.2 后端鉴权 + +`server-node` 为 `/api/runtime/story/*` 单独补一层兼容认证: + +1. 先尝试旧 JWT Bearer +2. 若 Bearer 不是 JWT 或 JWT 校验失败,则尝试按 STDB token 建连解析身份 +3. 解析成功后,把 `request.userId` 设为 STDB account id + +注意: + +1. 这不是把所有 `server-node` 路由都改成 STDB 认证 +2. 只对 runtime story 这条仍未迁完的兼容链路生效 + +### 3.3 快照读写 + +`storyActionService` 现有规则仍保持不变,但其读取与写回的 `runtimeRepository` 改为支持 STDB snapshot 的兼容仓储。 + +优先级: + +1. runtime story 若通过 STDB token 认证进入 +2. 则快照直接从 STDB `my_snapshot` / `save_snapshot` / `delete_snapshot` 读取和写回 +3. 旧 JWT 路径继续维持原来的 Postgres 仓储行为 + +这样可以最小化改动: + +1. 不重写 story rule +2. 不要求 runtime story 前端立刻切成 STDB provider +3. 先让“当前真实主快照”恢复一致 + +## 4. 为什么不选其他方案 + +### 4.1 不恢复前端双写到 Postgres + +不选原因: + +1. 会把已经迁到 STDB 的快照主链路重新拉回双写状态 +2. 会继续制造写时漂移和清理成本 +3. 与 express -> stdb 迁移方向相反 + +### 4.2 不把所有 `/api/runtime/story/*` 立刻删掉 + +不选原因: + +1. 现有 `storyActionService.ts` 承接了大量 battle / npc / inventory / quest 规则 +2. 一次性重写成 STDB procedure 风险过高 +3. 当前更紧急的是先修复“已断开的运行时快照来源” + +## 5. 本轮边界 + +本轮只做: + +1. runtime story 的 STDB token 兼容认证 +2. runtime story 的 STDB snapshot 兼容读写 +3. 保持现有前端 coordinator / response contract 不变 + +本轮不做: + +1. 新的 STDB runtime story table / procedure / view 正式 contract +2. `runtimeStoryService` 切为真正的 STDB transport +3. 删除 `/api/runtime/story/*` + +## 6. 下一步 + +Phase 2A 稳定后,再继续: + +1. 把 `get state` 和 `resolve action` 的正式 contract 收到 STDB +2. 在前端接入真正的 STDB transport +3. 删除 `/api/runtime/story/*` 这层兼容桥 diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index 10e77951..bf81ccdf 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -128,6 +128,10 @@ function createTestConfig( refreshCookieSameSite: 'Lax', refreshCookiePath: '/api/auth', }, + spacetime: { + uri: 'ws://127.0.0.1:3000', + databaseName: 'genarrative-test', + }, }; return { diff --git a/server-node/src/config.ts b/server-node/src/config.ts index d59dc300..e1934895 100644 --- a/server-node/src/config.ts +++ b/server-node/src/config.ts @@ -80,6 +80,10 @@ export type AppConfig = { refreshCookieSameSite: 'Lax' | 'Strict' | 'None'; refreshCookiePath: string; }; + spacetime: { + uri: string; + databaseName: string; + }; }; type LoadConfigOptions = { @@ -509,5 +513,17 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { '/api/auth', ), }, + spacetime: { + uri: readString( + env, + 'SPACETIME_URI', + readString(env, 'VITE_SPACETIME_URI', 'wss://maincloud.spacetimedb.com'), + ), + databaseName: readString( + env, + 'SPACETIME_DATABASE_NAME', + readString(env, 'VITE_SPACETIME_DATABASE_NAME', 'xushi-p4wfr'), + ), + }, }; } diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index b04d16be..9955bea0 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -87,6 +87,10 @@ function createTestConfig(databaseUrl: string): AppConfig { refreshCookieSameSite: 'Lax', refreshCookiePath: '/api/auth', }, + spacetime: { + uri: 'ws://127.0.0.1:3000', + databaseName: 'genarrative-test', + }, }; } diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/modules/story/storyActionRoutes.test.ts index aac1bf83..7274e1de 100644 --- a/server-node/src/modules/story/storyActionRoutes.test.ts +++ b/server-node/src/modules/story/storyActionRoutes.test.ts @@ -97,6 +97,10 @@ function createTestConfig(testName: string): AppConfig { refreshCookieSameSite: 'Lax', refreshCookiePath: '/api/auth', }, + spacetime: { + uri: 'ws://127.0.0.1:3000', + databaseName: 'genarrative-test', + }, }; } diff --git a/server-node/src/modules/story/storyActionRoutes.ts b/server-node/src/modules/story/storyActionRoutes.ts index f9bc124c..ca4d958d 100644 --- a/server-node/src/modules/story/storyActionRoutes.ts +++ b/server-node/src/modules/story/storyActionRoutes.ts @@ -11,6 +11,11 @@ import { getRuntimeStoryState, resolveRuntimeStoryAction, } from './storyActionService.js'; +import { + authenticateRuntimeStoryViaSpacetime, + createRuntimeStorySnapshotRepository, + shouldUseSpacetimeStoryAuth, +} from './storySpacetimeBridge.js'; const actionPayloadSchema = z.record(z.string(), z.unknown()); @@ -29,7 +34,17 @@ export function createStoryActionRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); - router.use(requireAuth); + router.use( + asyncHandler(async (request, _response, next) => { + if (shouldUseSpacetimeStoryAuth(request)) { + await authenticateRuntimeStoryViaSpacetime(request, context); + next(); + return; + } + request.runtimeStoryAuthMode = 'jwt'; + await requireAuth(request, _response, next); + }), + ); router.post( '/actions/resolve', @@ -41,7 +56,11 @@ export function createStoryActionRoutes(context: AppContext) { sendApiResponse( response, await resolveRuntimeStoryAction({ - runtimeRepository: context.runtimeRepository, + runtimeRepository: createRuntimeStorySnapshotRepository({ + request, + runtimeRepository: context.runtimeRepository, + config: context.config, + }), llmClient: context.llmClient, userId: request.userId!, request: payload, @@ -62,7 +81,11 @@ export function createStoryActionRoutes(context: AppContext) { sendApiResponse( response, await getRuntimeStoryState({ - runtimeRepository: context.runtimeRepository, + runtimeRepository: createRuntimeStorySnapshotRepository({ + request, + runtimeRepository: context.runtimeRepository, + config: context.config, + }), userId: request.userId!, sessionId, }), diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts index 15c84ef6..74a6ee76 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -6,7 +6,10 @@ import type { RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; import { conflict, invalidRequest } from '../../errors.js'; -import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js'; +import type { + RuntimeRepositoryPort, + SavedSnapshot, +} from '../../repositories/runtimeRepository.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; import { buildStrictNpcChatDialoguePrompt, @@ -77,6 +80,11 @@ type GeneratedStoryPayload = { savedCurrentStory: JsonRecord; }; +type RuntimeSnapshotRepositoryPort = Pick< + RuntimeRepositoryPort, + 'getSnapshot' | 'putSnapshot' +>; + const CONTINUE_ADVENTURE_OPTION = { functionId: 'story_continue_adventure', actionText: '继续冒险', @@ -855,7 +863,7 @@ function resolveStoryFlowAction( } export async function resolveRuntimeStoryAction(params: { - runtimeRepository: RuntimeRepositoryPort; + runtimeRepository: RuntimeSnapshotRepositoryPort; llmClient?: UpstreamLlmClient; userId: string; request: RuntimeStoryActionRequest; @@ -1058,7 +1066,7 @@ export async function resolveRuntimeStoryAction(params: { } export async function getRuntimeStoryState(params: { - runtimeRepository: RuntimeRepositoryPort; + runtimeRepository: Pick; userId: string; sessionId: string; }) { diff --git a/server-node/src/modules/story/storySpacetimeBridge.ts b/server-node/src/modules/story/storySpacetimeBridge.ts new file mode 100644 index 00000000..ac5129bf --- /dev/null +++ b/server-node/src/modules/story/storySpacetimeBridge.ts @@ -0,0 +1,196 @@ +import type { Request } from 'express'; +import { DbConnection } from '../../../../src/spacetime/generated/index.ts'; +import type { + RequestMeta, + SnapshotView, +} from '../../../../src/spacetime/generated/types.ts'; +import { conflict, unauthorized } from '../../errors.js'; +import type { + RuntimeRepositoryPort, + SavedSnapshot, +} from '../../repositories/runtimeRepository.js'; +import type { AppConfig } from '../../config.js'; +import { hydrateSavedSnapshot } from '../runtime/runtimeSnapshotHydration.js'; + +const STORY_STDB_AUTH_HEADER = 'x-genarrative-runtime-story-auth'; + +type RuntimeSnapshotRepositoryPort = Pick< + RuntimeRepositoryPort, + 'getSnapshot' | 'putSnapshot' +>; + +type StoryStdbBridgeContext = { + config: AppConfig; +}; + +function readBearerToken(request: Request) { + const authorization = request.header('authorization')?.trim() || ''; + if (!authorization.startsWith('Bearer ')) { + return ''; + } + return authorization.slice('Bearer '.length).trim(); +} + +function buildRequestMeta(request: Request): RequestMeta { + const userAgent = request.header('user-agent')?.trim() || undefined; + const forwardedFor = request.header('x-forwarded-for')?.split(',')[0]?.trim(); + const ip = forwardedFor || request.ip || undefined; + + return { + clientType: 'server-node-runtime-story-bridge', + userAgent, + ip, + }; +} + +function toSavedSnapshot(row: SnapshotView): SavedSnapshot { + return hydrateSavedSnapshot({ + version: Number(row.version), + savedAt: new Date(Number(row.savedAtMs)).toISOString(), + gameState: JSON.parse(row.gameStateJson) as Record, + bottomTab: row.bottomTab, + currentStory: row.currentStoryJson + ? (JSON.parse(row.currentStoryJson) as Record | null) + : null, + })!; +} + +async function connectWithToken(config: AppConfig, token: string) { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + + DbConnection.builder() + .withUri(config.spacetime.uri) + .withDatabaseName(config.spacetime.databaseName) + .withLightMode(true) + .withToken(token) + .onConnect((connection) => { + connection + .subscriptionBuilder() + .onApplied(() => { + finish(() => { + resolve(connection); + }); + }) + .onError((_ctx, error) => { + finish(() => { + connection.disconnect(); + reject(error); + }); + }) + .subscribeToAllTables(); + }) + .onConnectError((_ctx, error) => { + finish(() => reject(error)); + }) + .onDisconnect((_ctx, error) => { + if (!settled) { + finish(() => reject(error ?? new Error('Spacetime 连接已断开'))); + } + }) + .build(); + }); +} + +async function withBridgeConnection( + config: AppConfig, + token: string, + run: (connection: DbConnection) => Promise, +) { + const connection = await connectWithToken(config, token); + try { + return await run(connection); + } finally { + connection.disconnect(); + } +} + +export function shouldUseSpacetimeStoryAuth(request: Request) { + return ( + request.header(STORY_STDB_AUTH_HEADER)?.trim() === 'spacetime-token' + ); +} + +export async function authenticateRuntimeStoryViaSpacetime( + request: Request, + context: StoryStdbBridgeContext, +) { + const token = readBearerToken(request); + if (!token) { + throw unauthorized('缺少 runtime story 兼容认证 token'); + } + + const accountId = await withBridgeConnection( + context.config, + token, + async (connection) => { + const row = Array.from(connection.db.my_auth_state.iter())[0] ?? null; + const nextAccountId = row?.accountId?.trim() || ''; + if (!nextAccountId) { + throw unauthorized('runtime story STDB 账号态不存在'); + } + return nextAccountId; + }, + ); + + request.userId = accountId; + request.runtimeStoryAuthMode = 'spacetime'; +} + +export function createRuntimeStorySnapshotRepository(params: { + request: Request; + runtimeRepository: RuntimeRepositoryPort; + config: AppConfig; +}): RuntimeSnapshotRepositoryPort { + if (params.request.runtimeStoryAuthMode !== 'spacetime') { + return params.runtimeRepository; + } + + const token = readBearerToken(params.request); + if (!token) { + throw unauthorized('缺少 runtime story STDB token'); + } + + const requestMeta = buildRequestMeta(params.request); + + return { + async getSnapshot() { + return withBridgeConnection(params.config, token, async (connection) => { + const row = Array.from(connection.db.my_snapshot.iter())[0] ?? null; + return row ? toSavedSnapshot(row) : null; + }); + }, + async putSnapshot(_userId: string, payload) { + return withBridgeConnection(params.config, token, async (connection) => { + const result = await connection.procedures.saveSnapshot({ + meta: requestMeta, + savedAtMs: BigInt(Date.parse(payload.savedAt) || Date.now()), + gameStateJson: JSON.stringify(payload.gameState), + bottomTab: payload.bottomTab, + currentStoryJson: + payload.currentStory === null || payload.currentStory === undefined + ? undefined + : JSON.stringify(payload.currentStory), + }); + + if (!result.ok) { + throw conflict(result.message || 'runtime story STDB 快照写入失败'); + } + + const row = Array.from(connection.db.my_snapshot.iter())[0] ?? null; + if (!row) { + throw conflict('runtime story STDB 快照写入后未返回最新快照'); + } + + return toSavedSnapshot(row); + }); + }, + }; +} diff --git a/server-node/src/observability.test.ts b/server-node/src/observability.test.ts index e571bda7..4c6b8595 100644 --- a/server-node/src/observability.test.ts +++ b/server-node/src/observability.test.ts @@ -99,6 +99,10 @@ function createTestConfig(testName: string): AppConfig { refreshCookieSameSite: 'Lax', refreshCookiePath: '/api/auth', }, + spacetime: { + uri: 'ws://127.0.0.1:3000', + databaseName: 'genarrative-test', + }, }; } diff --git a/server-node/src/services/smsVerificationService.test.ts b/server-node/src/services/smsVerificationService.test.ts index 6d9280da..5646ad6d 100644 --- a/server-node/src/services/smsVerificationService.test.ts +++ b/server-node/src/services/smsVerificationService.test.ts @@ -39,6 +39,10 @@ function createAliyunSmsConfig(): AppConfig { blockPhoneDurationMinutes: 30, blockIpDurationMinutes: 30, }, + spacetime: { + uri: 'ws://127.0.0.1:3000', + databaseName: 'genarrative-test', + }, } as AppConfig; } diff --git a/server-node/src/types/express.d.ts b/server-node/src/types/express.d.ts index e3f1b32f..490a9a8e 100644 --- a/server-node/src/types/express.d.ts +++ b/server-node/src/types/express.d.ts @@ -4,6 +4,7 @@ declare global { requestId: string; requestStartedAt: number; userId?: string; + runtimeStoryAuthMode?: 'jwt' | 'spacetime'; auth?: { userId: string; tokenVersion: number; diff --git a/src/services/runtimeStoryService.test.ts b/src/services/runtimeStoryService.test.ts index 86874277..74bdad56 100644 --- a/src/services/runtimeStoryService.test.ts +++ b/src/services/runtimeStoryService.test.ts @@ -10,10 +10,16 @@ vi.mock('./apiClient', async () => { return { ...actual, requestJson: requestJsonMock, + getStoredAccessToken: vi.fn(() => ''), + getStoredSpacetimeToken: vi.fn(() => ''), }; }); import { AnimationState } from '../types'; +import { + getStoredAccessToken, + getStoredSpacetimeToken, +} from './apiClient'; import { buildStoryMomentFromRuntimeOptions, getRuntimeClientVersion, @@ -32,6 +38,8 @@ describe('runtimeStoryService', () => { beforeEach(() => { requestJsonMock.mockReset(); resetRuntimeStoryTransport(); + vi.mocked(getStoredAccessToken).mockReturnValue(''); + vi.mocked(getStoredSpacetimeToken).mockReturnValue(''); }); function createMockRuntimeResponse(overrides: Record = {}) { @@ -110,7 +118,33 @@ describe('runtimeStoryService', () => { }), }), '执行运行时动作失败', - expect.any(Object), + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), + ); + }); + + it('prefers spacetime token auth headers for runtime story compatibility bridge', async () => { + vi.mocked(getStoredAccessToken).mockReturnValue('legacy-http-token'); + vi.mocked(getStoredSpacetimeToken).mockReturnValue('stdb-token'); + requestJsonMock.mockResolvedValue(createMockRuntimeResponse()); + + await getRuntimeStoryState('runtime-main'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/story/state/runtime-main', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer stdb-token', + 'x-genarrative-runtime-story-auth': 'spacetime-token', + }), + }), + '读取运行时故事状态失败', + expect.objectContaining({ + skipAuth: true, + skipRefresh: true, + }), ); }); diff --git a/src/services/runtimeStoryService.ts b/src/services/runtimeStoryService.ts index d4dc79c1..b4ea9be8 100644 --- a/src/services/runtimeStoryService.ts +++ b/src/services/runtimeStoryService.ts @@ -16,7 +16,12 @@ import type { } from '../persistence/runtimeSnapshotTypes'; import type { GameState, StoryMoment, StoryOption } from '../types'; import { AnimationState } from '../types'; -import { type ApiRetryOptions, requestJson } from './apiClient'; +import { + getStoredAccessToken, + getStoredSpacetimeToken, + type ApiRetryOptions, + requestJson, +} from './apiClient'; const RUNTIME_STORY_API_BASE = '/api/runtime/story'; const DEFAULT_SESSION_ID = 'runtime-main'; @@ -33,6 +38,7 @@ const TASK5_RUNTIME_FUNCTION_ID_SET = new Set( const SERVER_RUNTIME_FUNCTION_ID_SET = new Set([ ...SERVER_RUNTIME_FUNCTION_IDS, ]); +const RUNTIME_STORY_STDB_AUTH_HEADER = 'x-genarrative-runtime-story-auth'; export type RuntimeStoryServiceOptions = { signal?: AbortSignal; @@ -64,6 +70,29 @@ export type RuntimeStoryTransport = { ) => Promise; }; +function withRuntimeStoryAuthHeaders(headers?: HeadersInit) { + const nextHeaders = + headers instanceof Headers + ? Object.fromEntries(headers.entries()) + : Array.isArray(headers) + ? Object.fromEntries(headers) + : { ...(headers ?? {}) }; + const httpAccessToken = getStoredAccessToken(); + const spacetimeToken = getStoredSpacetimeToken(); + const bearerToken = spacetimeToken || httpAccessToken; + + if (bearerToken) { + nextHeaders.Authorization = `Bearer ${bearerToken}`; + } + if (spacetimeToken) { + nextHeaders[RUNTIME_STORY_STDB_AUTH_HEADER] = 'spacetime-token'; + } else if (httpAccessToken) { + nextHeaders[RUNTIME_STORY_STDB_AUTH_HEADER] = 'http-access-token'; + } + + return nextHeaders; +} + function requestRuntimeStoryJson( path: string, init: RequestInit, @@ -75,9 +104,14 @@ function requestRuntimeStoryJson( { ...init, signal: options.signal, + headers: withRuntimeStoryAuthHeaders(init.headers), }, fallbackMessage, - { retry: options.retry ?? RUNTIME_STORY_RETRY }, + { + retry: options.retry ?? RUNTIME_STORY_RETRY, + skipAuth: true, + skipRefresh: true, + }, ); }