接通custom world session的STDB请求级bridge
This commit is contained in:
@@ -79,7 +79,7 @@
|
|||||||
### 4.2 Custom World
|
### 4.2 Custom World
|
||||||
|
|
||||||
1. 先把 `session / works / profile` 各自 capability 固化
|
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
|
3. 为 `agent session` 的会话存取提供 STDB provider
|
||||||
4. 保留 `LLM / asset / stream` 在 orchestrator 边界
|
4. 保留 `LLM / asset / stream` 在 orchestrator 边界
|
||||||
5. 等会话和作品真源稳定后,再搬纯状态推导逻辑
|
5. 等会话和作品真源稳定后,再搬纯状态推导逻辑
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { createApp } from './app.ts';
|
|||||||
import type { AppConfig } from './config.ts';
|
import type { AppConfig } from './config.ts';
|
||||||
import { prepareEventStreamResponse } from './http.ts';
|
import { prepareEventStreamResponse } from './http.ts';
|
||||||
import { requestIdMiddleware } from './middleware/requestId.ts';
|
import { requestIdMiddleware } from './middleware/requestId.ts';
|
||||||
|
import {
|
||||||
|
resetCustomWorldSessionBridgeConnectorForTest,
|
||||||
|
setCustomWorldSessionBridgeConnectorForTest,
|
||||||
|
} from './modules/runtime/customWorldSessionSpacetimeBridge.js';
|
||||||
import { createAppContext } from './server.ts';
|
import { createAppContext } from './server.ts';
|
||||||
import { createCustomWorldRuntimeProvider } from './services/customWorldRuntimeProvider.js';
|
import { createCustomWorldRuntimeProvider } from './services/customWorldRuntimeProvider.js';
|
||||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
|
import { createTestCustomWorldAgentSingleTurnLlmClient } from './services/customWorldAgentTestHelpers.js';
|
||||||
@@ -524,6 +528,19 @@ function withBearer(token: string, init: TestRequestInit = {}) {
|
|||||||
} satisfies 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 () => {
|
test('legacy json responses remain compatible and include response metadata headers', async () => {
|
||||||
await withTestServer('legacy-http', async ({ baseUrl }) => {
|
await withTestServer('legacy-http', async ({ baseUrl }) => {
|
||||||
const requestId = 'req-legacy-http';
|
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 () => {
|
test('profile dashboard aggregates wallet, play time and played works at the account level', async () => {
|
||||||
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
|
await withTestServer('profile-dashboard', async ({ baseUrl }) => {
|
||||||
const user = await authEntry(baseUrl, 'dashboard_user', 'secret123');
|
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 { z } from 'zod';
|
||||||
|
|
||||||
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||||
@@ -58,6 +58,11 @@ import {
|
|||||||
hydrateSavedSnapshot,
|
hydrateSavedSnapshot,
|
||||||
normalizeSavedSnapshotPayload,
|
normalizeSavedSnapshotPayload,
|
||||||
} from '../modules/runtime/runtimeSnapshotHydration.js';
|
} from '../modules/runtime/runtimeSnapshotHydration.js';
|
||||||
|
import {
|
||||||
|
authenticateCustomWorldSessionViaSpacetime,
|
||||||
|
createCustomWorldSessionRepository,
|
||||||
|
shouldUseSpacetimeCustomWorldSessionAuth,
|
||||||
|
} from '../modules/runtime/customWorldSessionSpacetimeBridge.js';
|
||||||
import {
|
import {
|
||||||
characterChatReplyRequestSchema,
|
characterChatReplyRequestSchema,
|
||||||
characterChatSuggestionsRequestSchema,
|
characterChatSuggestionsRequestSchema,
|
||||||
@@ -87,6 +92,7 @@ import {
|
|||||||
generateHighQualityNextStory,
|
generateHighQualityNextStory,
|
||||||
parseStoryRequest,
|
parseStoryRequest,
|
||||||
} from '../services/storyService.js';
|
} from '../services/storyService.js';
|
||||||
|
import { createCustomWorldRuntimeProvider } from '../services/customWorldRuntimeProvider.js';
|
||||||
import { createCustomWorldAgentRoutes } from './customWorldAgent.js';
|
import { createCustomWorldAgentRoutes } from './customWorldAgent.js';
|
||||||
|
|
||||||
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
const jsonObjectSchema = z.record(z.string(), z.unknown());
|
||||||
@@ -195,6 +201,17 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
npc: await generateSceneNpcForLandmark(context.llmClient, payload),
|
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(
|
router.get(
|
||||||
'/runtime/custom-world-gallery',
|
'/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(
|
router.use(
|
||||||
'/runtime/custom-world/agent',
|
'/runtime/custom-world/agent',
|
||||||
createCustomWorldAgentRoutes(context),
|
createCustomWorldAgentRoutes(context),
|
||||||
@@ -795,12 +823,13 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions',
|
'/runtime/custom-world/sessions',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.create' }),
|
routeMeta({ operation: 'runtime.customWorldSession.create' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
|
const customWorldRuntime = createRequestScopedCustomWorldRuntime(request);
|
||||||
const payload = customWorldSessionSchema.parse(
|
const payload = customWorldSessionSchema.parse(
|
||||||
request.body,
|
request.body,
|
||||||
) as CreateCustomWorldSessionRequest;
|
) as CreateCustomWorldSessionRequest;
|
||||||
sendApiResponse(
|
sendApiResponse(
|
||||||
response,
|
response,
|
||||||
await context.customWorldSessions.create(
|
await customWorldRuntime.customWorldSessions.create(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
payload.settingText,
|
payload.settingText,
|
||||||
payload.creatorIntent,
|
payload.creatorIntent,
|
||||||
@@ -814,7 +843,9 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions/:sessionId',
|
'/runtime/custom-world/sessions/:sessionId',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.get' }),
|
routeMeta({ operation: 'runtime.customWorldSession.get' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
const session = await context.customWorldSessions.get(
|
const session = await createRequestScopedCustomWorldRuntime(
|
||||||
|
request,
|
||||||
|
).customWorldSessions.get(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
);
|
);
|
||||||
@@ -829,10 +860,11 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions/:sessionId/answers',
|
'/runtime/custom-world/sessions/:sessionId/answers',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
|
routeMeta({ operation: 'runtime.customWorldSession.answer' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
|
const customWorldRuntime = createRequestScopedCustomWorldRuntime(request);
|
||||||
const payload = customWorldAnswerSchema.parse(
|
const payload = customWorldAnswerSchema.parse(
|
||||||
request.body,
|
request.body,
|
||||||
) as AnswerCustomWorldSessionQuestionRequest;
|
) as AnswerCustomWorldSessionQuestionRequest;
|
||||||
const session = await context.customWorldSessions.answer(
|
const session = await customWorldRuntime.customWorldSessions.answer(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
payload.questionId,
|
payload.questionId,
|
||||||
@@ -849,7 +881,8 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
'/runtime/custom-world/sessions/:sessionId/generate/stream',
|
||||||
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
|
routeMeta({ operation: 'runtime.customWorldSession.generateStream' }),
|
||||||
asyncHandler(async (request, response) => {
|
asyncHandler(async (request, response) => {
|
||||||
const session = await context.customWorldSessions.get(
|
const customWorldRuntime = createRequestScopedCustomWorldRuntime(request);
|
||||||
|
const session = await customWorldRuntime.customWorldSessions.get(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
);
|
);
|
||||||
@@ -870,7 +903,7 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
writeEvent('progress', { phase: 'preparing', progress: 10 });
|
||||||
await context.customWorldSessions.updateStatus(
|
await customWorldRuntime.customWorldSessions.updateStatus(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
'generating',
|
'generating',
|
||||||
@@ -887,7 +920,7 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await context.customWorldSessions.setResult(
|
await customWorldRuntime.customWorldSessions.setResult(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
profile,
|
profile,
|
||||||
@@ -900,7 +933,7 @@ export function createRuntimeRoutes(context: AppContext) {
|
|||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'custom world generation failed';
|
: 'custom world generation failed';
|
||||||
await context.customWorldSessions.updateStatus(
|
await customWorldRuntime.customWorldSessions.updateStatus(
|
||||||
request.userId!,
|
request.userId!,
|
||||||
readParam(request.params.sessionId),
|
readParam(request.params.sessionId),
|
||||||
'generation_error',
|
'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;
|
requestStartedAt: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
|
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
|
||||||
|
customWorldSessionAuthMode?: 'jwt' | 'spacetime';
|
||||||
auth?: {
|
auth?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
tokenVersion: number;
|
tokenVersion: number;
|
||||||
|
|||||||
@@ -51,12 +51,18 @@ import type {
|
|||||||
StoryRequestOptions,
|
StoryRequestOptions,
|
||||||
TextStreamOptions,
|
TextStreamOptions,
|
||||||
} from './aiTypes';
|
} from './aiTypes';
|
||||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
import {
|
||||||
|
fetchWithApiAuth,
|
||||||
|
getStoredAccessToken,
|
||||||
|
getStoredSpacetimeToken,
|
||||||
|
requestJson,
|
||||||
|
} from './apiClient';
|
||||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||||
import { parseLineListContent } from './llmParsers';
|
import { parseLineListContent } from './llmParsers';
|
||||||
|
|
||||||
const RUNTIME_API_BASE = '/api/runtime';
|
const RUNTIME_API_BASE = '/api/runtime';
|
||||||
const CUSTOM_WORLD_API_BASE = '/api';
|
const CUSTOM_WORLD_API_BASE = '/api';
|
||||||
|
const CUSTOM_WORLD_STDB_AUTH_HEADER = 'x-genarrative-custom-world-auth';
|
||||||
|
|
||||||
type LegacyAiModule = typeof import('./ai');
|
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(
|
async function requestPlainText(
|
||||||
url: string,
|
url: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
@@ -434,6 +463,11 @@ export async function streamCustomWorldSessionGeneration(
|
|||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
|
headers: withCustomWorldAuthHeaders(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -634,10 +668,16 @@ export async function createCustomWorldSession(payload: {
|
|||||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: withCustomWorldAuthHeaders({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
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)}`,
|
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
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`,
|
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: withCustomWorldAuthHeaders({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(
|
||||||
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
'提交自定义世界补充设定失败',
|
'提交自定义世界补充设定失败',
|
||||||
|
{
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user