From eadfd785d182e6bd8c183408a44544523637923f Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 20 Apr 2026 13:13:11 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E9=80=9Acustom=20world=20session?= =?UTF-8?q?=E7=9A=84STDB=E8=AF=B7=E6=B1=82=E7=BA=A7bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...O_STDB_CAPABILITY_FIRST_PLAN_2026-04-20.md | 2 +- server-node/src/app.test.ts | 105 ++++++++ .../customWorldSessionSpacetimeBridge.test.ts | 252 ++++++++++++++++++ .../customWorldSessionSpacetimeBridge.ts | 222 +++++++++++++++ server-node/src/routes/runtimeRoutes.ts | 51 +++- server-node/src/types/express.d.ts | 1 + src/services/aiService.ts | 57 +++- 7 files changed, 677 insertions(+), 13 deletions(-) create mode 100644 server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.test.ts create mode 100644 server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.ts diff --git a/docs/technical/STORY_WORLD_TO_STDB_CAPABILITY_FIRST_PLAN_2026-04-20.md b/docs/technical/STORY_WORLD_TO_STDB_CAPABILITY_FIRST_PLAN_2026-04-20.md index 455b3ffe..187de1cb 100644 --- a/docs/technical/STORY_WORLD_TO_STDB_CAPABILITY_FIRST_PLAN_2026-04-20.md +++ b/docs/technical/STORY_WORLD_TO_STDB_CAPABILITY_FIRST_PLAN_2026-04-20.md @@ -79,7 +79,7 @@ ### 4.2 Custom World 1. 先把 `session / works / profile` 各自 capability 固化 -2. 为 `custom world session` 提供 STDB provider +2. 为 `custom world session` 提供 STDB provider,并采用“按请求切换 provider”的桥接方式,不把 STDB token 依赖塞回全局 context 3. 为 `agent session` 的会话存取提供 STDB provider 4. 保留 `LLM / asset / stream` 在 orchestrator 边界 5. 等会话和作品真源稳定后,再搬纯状态推导逻辑 diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index bf2039d6..4b5c28ba 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -11,6 +11,10 @@ import { createApp } from './app.ts'; import type { AppConfig } from './config.ts'; import { prepareEventStreamResponse } from './http.ts'; import { requestIdMiddleware } from './middleware/requestId.ts'; +import { + resetCustomWorldSessionBridgeConnectorForTest, + setCustomWorldSessionBridgeConnectorForTest, +} from './modules/runtime/customWorldSessionSpacetimeBridge.js'; import { createAppContext } from './server.ts'; import { createCustomWorldRuntimeProvider } from './services/customWorldRuntimeProvider.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js'; @@ -524,6 +528,19 @@ function withBearer(token: string, init: TestRequestInit = {}) { } satisfies TestRequestInit; } +function withCustomWorldSpacetimeBearer( + token: string, + init: TestRequestInit = {}, +) { + return { + ...withBearer(token, init), + headers: { + ...(withBearer(token, init).headers ?? {}), + 'x-genarrative-custom-world-auth': 'spacetime-token', + }, + } satisfies TestRequestInit; +} + test('legacy json responses remain compatible and include response metadata headers', async () => { await withTestServer('legacy-http', async ({ baseUrl }) => { const requestId = 'req-legacy-http'; @@ -1890,6 +1907,94 @@ test('runtime persistence is isolated by user', async () => { }); }); +test('custom world session supports spacetime auth header over http', async () => { + await withTestServer('custom-world-session-spacetime-http', async ({ baseUrl }) => { + const storedSessions = new Map< + string, + { + sessionId: string; + payloadJson: string; + createdAtMs: bigint; + updatedAtMs: bigint; + } + >(); + setCustomWorldSessionBridgeConnectorForTest(async () => ({ + db: { + my_auth_state: { + iter: () => + [ + { + accountId: 'user_1', + }, + ][Symbol.iterator](), + }, + my_custom_world_sessions: { + iter: () => [...storedSessions.values()][Symbol.iterator](), + }, + }, + procedures: { + async upsertCustomWorldSession(input) { + storedSessions.set(String(input.sessionId), { + sessionId: String(input.sessionId), + payloadJson: String(input.payloadJson), + createdAtMs: input.createdAtMs as bigint, + updatedAtMs: input.updatedAtMs as bigint, + }); + return { + ok: true, + message: 'ok', + }; + }, + }, + disconnect() {}, + })); + + const entry = await authEntry( + baseUrl, + 'custom_world_stdb_user', + 'secret123', + ); + + const createResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/sessions`, + withCustomWorldSpacetimeBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + settingText: '一个被潮雾切开的列岛世界。', + creatorIntent: { + worldHook: '潮雾会改变海图与记忆。', + }, + generationMode: 'fast', + }), + }), + ); + const created = (await createResponse.json()) as { + sessionId: string; + status: string; + questions: Array<{ id: string }>; + }; + + assert.equal(createResponse.status, 200); + assert.ok(created.sessionId); + + const getResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/sessions/${encodeURIComponent(created.sessionId)}`, + withCustomWorldSpacetimeBearer(entry.token), + ); + const session = (await getResponse.json()) as { + sessionId: string; + settingText: string; + generationMode: string; + }; + + assert.equal(getResponse.status, 200); + assert.equal(session.sessionId, created.sessionId); + assert.equal(session.settingText, '一个被潮雾切开的列岛世界。'); + assert.equal(session.generationMode, 'fast'); + }); + resetCustomWorldSessionBridgeConnectorForTest(); +}); + test('profile dashboard aggregates wallet, play time and played works at the account level', async () => { await withTestServer('profile-dashboard', async ({ baseUrl }) => { const user = await authEntry(baseUrl, 'dashboard_user', 'secret123'); diff --git a/server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.test.ts b/server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.test.ts new file mode 100644 index 00000000..f68771ab --- /dev/null +++ b/server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.test.ts @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { Request } from 'express'; + +import type { AppConfig } from '../../config.js'; +import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js'; +import { + authenticateCustomWorldSessionViaSpacetime, + createCustomWorldSessionRepository, + resetCustomWorldSessionBridgeConnectorForTest, + setCustomWorldSessionBridgeConnectorForTest, + shouldUseSpacetimeCustomWorldSessionAuth, +} from './customWorldSessionSpacetimeBridge.js'; + +function createTestConfig(): AppConfig { + return { + nodeEnv: 'test', + projectRoot: '/tmp/genarrative-test', + publicDir: '/tmp/genarrative-test/public', + logsDir: '/tmp/genarrative-test/logs', + dataDir: '/tmp/genarrative-test/data', + rawEnv: {}, + databaseUrl: 'pg-mem://genarrative-test', + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, + spacetime: { + uri: 'ws://127.0.0.1:3000', + databaseName: 'genarrative-test', + }, + }; +} + +function createRequest(headers: Record = {}) { + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + + return { + header(name: string) { + return normalizedHeaders[name.toLowerCase()] ?? undefined; + }, + ip: '127.0.0.1', + userId: undefined, + customWorldSessionAuthMode: undefined, + } as unknown as Request; +} + +function createSessionRecord(): CustomWorldSessionRecord { + return { + sessionId: 'cw-session-01', + status: 'ready_to_generate', + settingText: '潮雾列岛', + creatorIntent: null, + generationMode: 'fast', + questions: [], + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:01:00.000Z', + }; +} + +test('shouldUseSpacetimeCustomWorldSessionAuth only accepts the custom world spacetime marker', () => { + assert.equal( + shouldUseSpacetimeCustomWorldSessionAuth( + createRequest({ + 'x-genarrative-custom-world-auth': 'spacetime-token', + }), + ), + true, + ); + assert.equal( + shouldUseSpacetimeCustomWorldSessionAuth( + createRequest({ + 'x-genarrative-custom-world-auth': 'http-access-token', + }), + ), + false, + ); +}); + +test('authenticateCustomWorldSessionViaSpacetime resolves account id from bridge connection', async () => { + setCustomWorldSessionBridgeConnectorForTest(async () => ({ + db: { + my_auth_state: { + iter: () => + [ + { + accountId: 'stdb-account-02', + }, + ][Symbol.iterator](), + }, + }, + disconnect() {}, + })); + + const request = createRequest({ + authorization: 'Bearer stdb-token', + }); + + await authenticateCustomWorldSessionViaSpacetime(request, { + config: createTestConfig(), + }); + + assert.equal(request.userId, 'stdb-account-02'); + assert.equal(request.customWorldSessionAuthMode, 'spacetime'); + + resetCustomWorldSessionBridgeConnectorForTest(); +}); + +test('createCustomWorldSessionRepository uses STDB rows when request is marked as spacetime', async () => { + let savedInput: Record | null = null; + const sessionRecord = createSessionRecord(); + const payloadJson = JSON.stringify(sessionRecord); + + setCustomWorldSessionBridgeConnectorForTest(async () => ({ + db: { + my_custom_world_sessions: { + iter: () => + [ + { + sessionId: sessionRecord.sessionId, + payloadJson, + createdAtMs: BigInt(Date.parse(sessionRecord.createdAt)), + updatedAtMs: BigInt(Date.parse(sessionRecord.updatedAt)), + }, + ][Symbol.iterator](), + }, + }, + procedures: { + async upsertCustomWorldSession(input: Record) { + savedInput = input; + return { + ok: true, + message: 'ok', + }; + }, + }, + disconnect() {}, + })); + + const request = createRequest({ + authorization: 'Bearer stdb-token', + 'user-agent': 'test-agent', + }); + request.customWorldSessionAuthMode = 'spacetime'; + + const repository = createCustomWorldSessionRepository({ + request, + config: createTestConfig(), + runtimeRepository: { + async listCustomWorldSessions() { + throw new Error('should not hit legacy repository'); + }, + async getCustomWorldSession() { + throw new Error('should not hit legacy repository'); + }, + async upsertCustomWorldSession() { + throw new Error('should not hit legacy repository'); + }, + }, + }); + + const sessions = await repository.listCustomWorldSessions('ignored-user-id'); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.sessionId, sessionRecord.sessionId); + + const loaded = await repository.getCustomWorldSession( + 'ignored-user-id', + sessionRecord.sessionId, + ); + assert.equal(loaded?.settingText, sessionRecord.settingText); + + const persisted = await repository.upsertCustomWorldSession( + 'ignored-user-id', + sessionRecord.sessionId, + sessionRecord, + ); + assert.equal(persisted.sessionId, sessionRecord.sessionId); + assert.equal(savedInput?.sessionId, sessionRecord.sessionId); + assert.equal(savedInput?.payloadJson, payloadJson); + + resetCustomWorldSessionBridgeConnectorForTest(); +}); diff --git a/server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.ts b/server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.ts new file mode 100644 index 00000000..c77b5f82 --- /dev/null +++ b/server-node/src/modules/runtime/customWorldSessionSpacetimeBridge.ts @@ -0,0 +1,222 @@ +import type { Request } from 'express'; + +import { DbConnection } from '../../../../src/spacetime/generated/index.ts'; +import type { + CustomWorldSessionView, + RequestMeta, +} from '../../../../src/spacetime/generated/types.ts'; +import { conflict, unauthorized } from '../../errors.js'; +import type { AppConfig } from '../../config.js'; +import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js'; +import type { CustomWorldSessionCapability } from '../../services/runtimeCapabilities.js'; + +const CUSTOM_WORLD_SESSION_STDB_AUTH_HEADER = + 'x-genarrative-custom-world-auth'; + +type CustomWorldSessionStdbBridgeContext = { + config: AppConfig; +}; + +type CustomWorldSessionBridgeDbConnection = Awaited< + ReturnType +>; + +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-custom-world-session-bridge', + userAgent, + ip, + }; +} + +function parseCustomWorldSessionRow( + row: CustomWorldSessionView, +): CustomWorldSessionRecord | null { + try { + const payload = JSON.parse(row.payloadJson) as CustomWorldSessionRecord; + return { + ...payload, + sessionId: row.sessionId, + createdAt: new Date(Number(row.createdAtMs)).toISOString(), + updatedAt: new Date(Number(row.updatedAtMs)).toISOString(), + }; + } catch { + return 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: CustomWorldSessionBridgeDbConnection) => Promise, +) { + const connection = await customWorldSessionBridgeConnector(config, token); + try { + return await run(connection); + } finally { + connection.disconnect(); + } +} + +let customWorldSessionBridgeConnector = connectWithToken; + +export function setCustomWorldSessionBridgeConnectorForTest( + connector: typeof connectWithToken, +) { + customWorldSessionBridgeConnector = connector; +} + +export function resetCustomWorldSessionBridgeConnectorForTest() { + customWorldSessionBridgeConnector = connectWithToken; +} + +export function shouldUseSpacetimeCustomWorldSessionAuth(request: Request) { + return ( + request.header(CUSTOM_WORLD_SESSION_STDB_AUTH_HEADER)?.trim() === + 'spacetime-token' + ); +} + +export async function authenticateCustomWorldSessionViaSpacetime( + request: Request, + context: CustomWorldSessionStdbBridgeContext, +) { + const token = readBearerToken(request); + if (!token) { + throw unauthorized('缺少 custom world session 兼容认证 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('custom world session STDB 账号态不存在'); + } + return nextAccountId; + }, + ); + + request.userId = accountId; + request.customWorldSessionAuthMode = 'spacetime'; +} + +export function createCustomWorldSessionRepository(params: { + request: Request; + runtimeRepository: CustomWorldSessionCapability; + config: AppConfig; +}): CustomWorldSessionCapability { + if (params.request.customWorldSessionAuthMode !== 'spacetime') { + return params.runtimeRepository; + } + + const token = readBearerToken(params.request); + if (!token) { + throw unauthorized('缺少 custom world session STDB token'); + } + + const requestMeta = buildRequestMeta(params.request); + + return { + async listCustomWorldSessions() { + return withBridgeConnection(params.config, token, async (connection) => { + return Array.from(connection.db.my_custom_world_sessions.iter()) + .map(parseCustomWorldSessionRow) + .filter((row): row is CustomWorldSessionRecord => Boolean(row)) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + }); + }, + async getCustomWorldSession(_userId: string, sessionId: string) { + return withBridgeConnection(params.config, token, async (connection) => { + const row = + Array.from(connection.db.my_custom_world_sessions.iter()).find( + (entry) => entry.sessionId === sessionId, + ) ?? null; + return row ? parseCustomWorldSessionRow(row) : null; + }); + }, + async upsertCustomWorldSession(_userId: string, sessionId: string, session) { + return withBridgeConnection(params.config, token, async (connection) => { + const result = await connection.procedures.upsertCustomWorldSession({ + meta: requestMeta, + sessionId, + payloadJson: JSON.stringify(session), + createdAtMs: BigInt(Date.parse(session.createdAt) || Date.now()), + updatedAtMs: BigInt(Date.parse(session.updatedAt) || Date.now()), + }); + + if (!result.ok) { + throw conflict(result.message || 'custom world session STDB 写入失败'); + } + + const row = + Array.from(connection.db.my_custom_world_sessions.iter()).find( + (entry) => entry.sessionId === sessionId, + ) ?? null; + const parsedRow = row ? parseCustomWorldSessionRow(row) : null; + if (!parsedRow) { + throw conflict('custom world session STDB 写入后未返回最新会话'); + } + return parsedRow; + }); + }, + }; +} diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index 962a72de..a928426a 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router, type Request } from 'express'; import { z } from 'zod'; import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js'; @@ -58,6 +58,11 @@ import { hydrateSavedSnapshot, normalizeSavedSnapshotPayload, } from '../modules/runtime/runtimeSnapshotHydration.js'; +import { + authenticateCustomWorldSessionViaSpacetime, + createCustomWorldSessionRepository, + shouldUseSpacetimeCustomWorldSessionAuth, +} from '../modules/runtime/customWorldSessionSpacetimeBridge.js'; import { characterChatReplyRequestSchema, characterChatSuggestionsRequestSchema, @@ -87,6 +92,7 @@ import { generateHighQualityNextStory, parseStoryRequest, } from '../services/storyService.js'; +import { createCustomWorldRuntimeProvider } from '../services/customWorldRuntimeProvider.js'; import { createCustomWorldAgentRoutes } from './customWorldAgent.js'; const jsonObjectSchema = z.record(z.string(), z.unknown()); @@ -195,6 +201,17 @@ export function createRuntimeRoutes(context: AppContext) { npc: await generateSceneNpcForLandmark(context.llmClient, payload), }); }); + const createRequestScopedCustomWorldRuntime = (request: Request) => + createCustomWorldRuntimeProvider({ + customWorldSessionCapability: createCustomWorldSessionRepository({ + request, + runtimeRepository: context.customWorldSessionCapability, + config: context.config, + }), + customWorldProfileCapability: context.customWorldProfileCapability, + llmClient: context.llmClient, + runtimeLlmClient: context.llmClient, + }); router.get( '/runtime/custom-world-gallery', @@ -231,7 +248,18 @@ export function createRuntimeRoutes(context: AppContext) { }), ); - router.use(requireAuth); + router.use( + asyncHandler(async (request, response, next) => { + if (shouldUseSpacetimeCustomWorldSessionAuth(request)) { + await authenticateCustomWorldSessionViaSpacetime(request, context); + next(); + return; + } + + request.customWorldSessionAuthMode = 'jwt'; + await requireAuth(request, response, next); + }), + ); router.use( '/runtime/custom-world/agent', createCustomWorldAgentRoutes(context), @@ -795,12 +823,13 @@ export function createRuntimeRoutes(context: AppContext) { '/runtime/custom-world/sessions', routeMeta({ operation: 'runtime.customWorldSession.create' }), asyncHandler(async (request, response) => { + const customWorldRuntime = createRequestScopedCustomWorldRuntime(request); const payload = customWorldSessionSchema.parse( request.body, ) as CreateCustomWorldSessionRequest; sendApiResponse( response, - await context.customWorldSessions.create( + await customWorldRuntime.customWorldSessions.create( request.userId!, payload.settingText, payload.creatorIntent, @@ -814,7 +843,9 @@ export function createRuntimeRoutes(context: AppContext) { '/runtime/custom-world/sessions/:sessionId', routeMeta({ operation: 'runtime.customWorldSession.get' }), asyncHandler(async (request, response) => { - const session = await context.customWorldSessions.get( + const session = await createRequestScopedCustomWorldRuntime( + request, + ).customWorldSessions.get( request.userId!, readParam(request.params.sessionId), ); @@ -829,10 +860,11 @@ export function createRuntimeRoutes(context: AppContext) { '/runtime/custom-world/sessions/:sessionId/answers', routeMeta({ operation: 'runtime.customWorldSession.answer' }), asyncHandler(async (request, response) => { + const customWorldRuntime = createRequestScopedCustomWorldRuntime(request); const payload = customWorldAnswerSchema.parse( request.body, ) as AnswerCustomWorldSessionQuestionRequest; - const session = await context.customWorldSessions.answer( + const session = await customWorldRuntime.customWorldSessions.answer( request.userId!, readParam(request.params.sessionId), payload.questionId, @@ -849,7 +881,8 @@ export function createRuntimeRoutes(context: AppContext) { '/runtime/custom-world/sessions/:sessionId/generate/stream', routeMeta({ operation: 'runtime.customWorldSession.generateStream' }), asyncHandler(async (request, response) => { - const session = await context.customWorldSessions.get( + const customWorldRuntime = createRequestScopedCustomWorldRuntime(request); + const session = await customWorldRuntime.customWorldSessions.get( request.userId!, readParam(request.params.sessionId), ); @@ -870,7 +903,7 @@ export function createRuntimeRoutes(context: AppContext) { }; writeEvent('progress', { phase: 'preparing', progress: 10 }); - await context.customWorldSessions.updateStatus( + await customWorldRuntime.customWorldSessions.updateStatus( request.userId!, readParam(request.params.sessionId), 'generating', @@ -887,7 +920,7 @@ export function createRuntimeRoutes(context: AppContext) { ); }, }); - await context.customWorldSessions.setResult( + await customWorldRuntime.customWorldSessions.setResult( request.userId!, readParam(request.params.sessionId), profile, @@ -900,7 +933,7 @@ export function createRuntimeRoutes(context: AppContext) { error instanceof Error ? error.message : 'custom world generation failed'; - await context.customWorldSessions.updateStatus( + await customWorldRuntime.customWorldSessions.updateStatus( request.userId!, readParam(request.params.sessionId), 'generation_error', diff --git a/server-node/src/types/express.d.ts b/server-node/src/types/express.d.ts index 490a9a8e..412bd99f 100644 --- a/server-node/src/types/express.d.ts +++ b/server-node/src/types/express.d.ts @@ -5,6 +5,7 @@ declare global { requestStartedAt: number; userId?: string; runtimeStoryAuthMode?: 'jwt' | 'spacetime'; + customWorldSessionAuthMode?: 'jwt' | 'spacetime'; auth?: { userId: string; tokenVersion: number; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 8a539ddd..6f41c882 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -51,12 +51,18 @@ import type { StoryRequestOptions, TextStreamOptions, } from './aiTypes'; -import { fetchWithApiAuth, requestJson } from './apiClient'; +import { + fetchWithApiAuth, + getStoredAccessToken, + getStoredSpacetimeToken, + requestJson, +} from './apiClient'; import { type CharacterChatTargetStatus } from './characterChatPrompt'; import { parseLineListContent } from './llmParsers'; const RUNTIME_API_BASE = '/api/runtime'; const CUSTOM_WORLD_API_BASE = '/api'; +const CUSTOM_WORLD_STDB_AUTH_HEADER = 'x-genarrative-custom-world-auth'; type LegacyAiModule = typeof import('./ai'); @@ -86,6 +92,29 @@ async function requestPostJson( ); } +function withCustomWorldAuthHeaders(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[CUSTOM_WORLD_STDB_AUTH_HEADER] = 'spacetime-token'; + } else if (httpAccessToken) { + nextHeaders[CUSTOM_WORLD_STDB_AUTH_HEADER] = 'http-access-token'; + } + + return nextHeaders; +} + async function requestPlainText( url: string, payload: unknown, @@ -434,6 +463,11 @@ export async function streamCustomWorldSessionGeneration( { method: 'GET', signal: options.signal, + headers: withCustomWorldAuthHeaders(), + }, + { + skipAuth: true, + skipRefresh: true, }, ); if (!response.ok) { @@ -634,10 +668,16 @@ export async function createCustomWorldSession(payload: { `${RUNTIME_API_BASE}/custom-world/sessions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: withCustomWorldAuthHeaders({ + 'Content-Type': 'application/json', + }), body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest), }, '创建自定义世界会话失败', + { + skipAuth: true, + skipRefresh: true, + }, ); } @@ -847,8 +887,13 @@ export async function getCustomWorldSession(sessionId: string) { `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`, { method: 'GET', + headers: withCustomWorldAuthHeaders(), }, '读取自定义世界会话失败', + { + skipAuth: true, + skipRefresh: true, + }, ); } @@ -860,12 +905,18 @@ export async function answerCustomWorldSessionQuestion( `${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: withCustomWorldAuthHeaders({ + 'Content-Type': 'application/json', + }), body: JSON.stringify( payload satisfies AnswerCustomWorldSessionQuestionRequest, ), }, '提交自定义世界补充设定失败', + { + skipAuth: true, + skipRefresh: true, + }, ); }