接通custom world session的STDB请求级bridge
This commit is contained in:
@@ -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. 等会话和作品真源稳定后,再搬纯状态推导逻辑
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, string> = {}) {
|
||||
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<string, unknown> | 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<string, unknown>) {
|
||||
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();
|
||||
});
|
||||
@@ -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<typeof connectWithToken>
|
||||
>;
|
||||
|
||||
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<DbConnection>((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<T>(
|
||||
config: AppConfig,
|
||||
token: string,
|
||||
run: (connection: CustomWorldSessionBridgeDbConnection) => Promise<T>,
|
||||
) {
|
||||
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;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
1
server-node/src/types/express.d.ts
vendored
1
server-node/src/types/express.d.ts
vendored
@@ -5,6 +5,7 @@ declare global {
|
||||
requestStartedAt: number;
|
||||
userId?: string;
|
||||
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
|
||||
customWorldSessionAuthMode?: 'jwt' | 'spacetime';
|
||||
auth?: {
|
||||
userId: string;
|
||||
tokenVersion: number;
|
||||
|
||||
@@ -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<T>(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user