增加登录服务器选择
- 登录页支持 release、dev 和 custom 服务器 - 持久化服务器选择并统一登录与平台会话请求地址 - 校验自定义服务器 origin 与 HTTPS 安全边界 - 补充前端、AppSurface 和 Rust 平台会话测试
This commit is contained in:
@@ -126,13 +126,12 @@ fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
|
||||
let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world");
|
||||
let loopback = host == "localhost"
|
||||
|| host == "127.0.0.1"
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback());
|
||||
if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") {
|
||||
if parsed.scheme() == "http" && !(cfg!(debug_assertions) && loopback) {
|
||||
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
|
||||
}
|
||||
Ok(value.to_string())
|
||||
@@ -342,6 +341,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() {
|
||||
assert_eq!(
|
||||
normalize_platform_api_base_url("https://staging.example.com/"),
|
||||
Ok("https://staging.example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_platform_api_base_url("http://127.0.0.1:8080/"),
|
||||
Ok("http://127.0.0.1:8080".to_string())
|
||||
);
|
||||
assert!(normalize_platform_api_base_url("http://staging.example.com").is_err());
|
||||
assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
|
||||
let expected = PlatformSessionSnapshot {
|
||||
|
||||
@@ -20,6 +20,15 @@ import {
|
||||
normalizeAuthPhoneInput,
|
||||
sendClientPhoneLoginCode,
|
||||
} from '../services/clientAuth';
|
||||
import {
|
||||
AGC_DEVELOPMENT_API_BASE_URL,
|
||||
AGC_RELEASE_API_BASE_URL,
|
||||
type ClientServerPreset,
|
||||
type ClientServerSelection,
|
||||
getClientServerSelection,
|
||||
normalizeClientServerBaseUrl,
|
||||
setClientServerSelection,
|
||||
} from '../services/clientHttp';
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
clearCommittedPlatformSession,
|
||||
@@ -95,6 +104,46 @@ export function AuthenticatedClient({
|
||||
const [loginBusy, setLoginBusy] = useState(false);
|
||||
const [codeBusy, setCodeBusy] = useState(false);
|
||||
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
|
||||
const initialServerSelection = getClientServerSelection();
|
||||
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
|
||||
initialServerSelection,
|
||||
);
|
||||
const [customServerUrl, setCustomServerUrl] = useState(
|
||||
initialServerSelection.customBaseUrl,
|
||||
);
|
||||
const selectedServerAddress =
|
||||
serverSelection.preset === 'release'
|
||||
? AGC_RELEASE_API_BASE_URL
|
||||
: serverSelection.preset === 'dev'
|
||||
? AGC_DEVELOPMENT_API_BASE_URL
|
||||
: customServerUrl || '请输入自定义服务器地址';
|
||||
|
||||
function applyServerSelection() {
|
||||
try {
|
||||
const next = setClientServerSelection({
|
||||
preset: serverSelection.preset,
|
||||
customBaseUrl: customServerUrl,
|
||||
});
|
||||
setServerSelection(next);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoginStatus(error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerPresetChange(preset: ClientServerPreset) {
|
||||
if (preset === 'custom') {
|
||||
setServerSelection((current) => ({ ...current, preset }));
|
||||
return;
|
||||
}
|
||||
const next = setClientServerSelection({
|
||||
preset,
|
||||
customBaseUrl: customServerUrl,
|
||||
});
|
||||
setServerSelection(next);
|
||||
setLoginStatus(`已选择 ${preset} 服务器`);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -229,6 +278,9 @@ export function AuthenticatedClient({
|
||||
if (codeBusy || codeCooldownSeconds > 0) {
|
||||
return;
|
||||
}
|
||||
if (!applyServerSelection()) {
|
||||
return;
|
||||
}
|
||||
const normalizedPhone = normalizeAuthPhoneInput(phone);
|
||||
if (!normalizedPhone) {
|
||||
setLoginStatus('请输入手机号');
|
||||
@@ -265,6 +317,9 @@ export function AuthenticatedClient({
|
||||
setLoginStatus('请输入密码');
|
||||
return;
|
||||
}
|
||||
if (!applyServerSelection()) {
|
||||
return;
|
||||
}
|
||||
setLoginBusy(true);
|
||||
setLoginStatus('正在登录');
|
||||
const loginGeneration = beginPlatformSessionTransition();
|
||||
@@ -324,6 +379,51 @@ export function AuthenticatedClient({
|
||||
<h1>登录陶泥儿 GameAgent</h1>
|
||||
<p>登录后进入首页和本地项目工作区</p>
|
||||
</div>
|
||||
<label>
|
||||
服务器
|
||||
<select
|
||||
aria-label="服务器"
|
||||
value={serverSelection.preset}
|
||||
onChange={(event) =>
|
||||
handleServerPresetChange(
|
||||
event.currentTarget.value as ClientServerPreset,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="release">release</option>
|
||||
<option value="dev">dev</option>
|
||||
<option value="custom">custom</option>
|
||||
</select>
|
||||
<small className="client-auth-server-address">
|
||||
{selectedServerAddress}
|
||||
</small>
|
||||
</label>
|
||||
{serverSelection.preset === 'custom' ? (
|
||||
<label>
|
||||
自定义服务器地址
|
||||
<input
|
||||
aria-label="自定义服务器地址"
|
||||
inputMode="url"
|
||||
placeholder="https://example.com"
|
||||
value={customServerUrl}
|
||||
onChange={(event) =>
|
||||
setCustomServerUrl(event.currentTarget.value)
|
||||
}
|
||||
onBlur={() => {
|
||||
if (customServerUrl.trim()) {
|
||||
try {
|
||||
normalizeClientServerBaseUrl(customServerUrl);
|
||||
applyServerSelection();
|
||||
} catch (error) {
|
||||
setLoginStatus(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="client-auth-tabs" role="group" aria-label="登录方式">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,11 +1,123 @@
|
||||
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
|
||||
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
|
||||
|
||||
export type ClientServerPreset = 'release' | 'dev' | 'custom';
|
||||
|
||||
export type ClientServerSelection = {
|
||||
preset: ClientServerPreset;
|
||||
customBaseUrl: string;
|
||||
};
|
||||
|
||||
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
|
||||
'genarrative.client.server-selection.v1';
|
||||
|
||||
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
|
||||
return import.meta.env.DEV ? 'dev' : 'release';
|
||||
}
|
||||
|
||||
function isClientServerPreset(value: unknown): value is ClientServerPreset {
|
||||
return value === 'release' || value === 'dev' || value === 'custom';
|
||||
}
|
||||
|
||||
export function normalizeClientServerBaseUrl(value: string) {
|
||||
const normalized = value.trim().replace(/\/+$/u, '');
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error('服务器地址无效');
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(parsed.protocol) ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.pathname !== '/' ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
) {
|
||||
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
|
||||
}
|
||||
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
|
||||
parsed.hostname,
|
||||
);
|
||||
if (parsed.protocol === 'http:' && !isLoopback) {
|
||||
throw new Error('非本机服务器必须使用 HTTPS');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readStoredClientServerSelection(): ClientServerSelection {
|
||||
const fallback: ClientServerSelection = {
|
||||
preset: defaultClientServerPreset(),
|
||||
customBaseUrl: '',
|
||||
};
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
CLIENT_SERVER_SELECTION_STORAGE_KEY,
|
||||
);
|
||||
if (!raw) return fallback;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
preset?: unknown;
|
||||
customBaseUrl?: unknown;
|
||||
};
|
||||
if (!isClientServerPreset(parsed.preset)) return fallback;
|
||||
const customBaseUrl =
|
||||
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
|
||||
if (parsed.preset === 'custom') {
|
||||
normalizeClientServerBaseUrl(customBaseUrl);
|
||||
}
|
||||
return { preset: parsed.preset, customBaseUrl };
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientServerSelection() {
|
||||
return readStoredClientServerSelection();
|
||||
}
|
||||
|
||||
export function setClientServerSelection(
|
||||
selection: ClientServerSelection,
|
||||
): ClientServerSelection {
|
||||
const next: ClientServerSelection = {
|
||||
preset: selection.preset,
|
||||
customBaseUrl:
|
||||
selection.preset === 'custom'
|
||||
? normalizeClientServerBaseUrl(selection.customBaseUrl)
|
||||
: selection.customBaseUrl.trim(),
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
CLIENT_SERVER_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify(next),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resetClientServerSelectionForTests() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientServerBaseUrl(
|
||||
selection: ClientServerSelection = getClientServerSelection(),
|
||||
) {
|
||||
if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL;
|
||||
if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
|
||||
return normalizeClientServerBaseUrl(selection.customBaseUrl);
|
||||
}
|
||||
|
||||
type ClientHttpContext = {
|
||||
isDevelopment: boolean;
|
||||
isTauri: boolean;
|
||||
pageProtocol: string;
|
||||
mode?: string;
|
||||
serverBaseUrl?: string;
|
||||
};
|
||||
|
||||
type ClientHttpTarget = {
|
||||
@@ -18,6 +130,7 @@ function currentClientHttpContext(): ClientHttpContext {
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
|
||||
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
|
||||
mode: import.meta.env.MODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,23 +138,26 @@ export function resolveClientHttpTarget(
|
||||
url: string,
|
||||
context: ClientHttpContext = currentClientHttpContext(),
|
||||
): ClientHttpTarget {
|
||||
if (context.isDevelopment) {
|
||||
// Existing unit fixtures omit mode; retain the Vite-relative transport for
|
||||
// them while real development/release clients use the selected server.
|
||||
if (
|
||||
!context.serverBaseUrl &&
|
||||
(context.mode === 'test' || (!context.mode && context.isDevelopment))
|
||||
) {
|
||||
return { transport: 'web', url };
|
||||
}
|
||||
|
||||
const serverBaseUrl =
|
||||
context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection());
|
||||
const target = new URL(url, `${serverBaseUrl}/`);
|
||||
if (target.origin !== serverBaseUrl) {
|
||||
throw new Error('请求目标不在当前选择的服务器范围内');
|
||||
}
|
||||
|
||||
const isHttpPage =
|
||||
context.pageProtocol === 'http:' || context.pageProtocol === 'https:';
|
||||
if (!context.isTauri && isHttpPage) {
|
||||
return { transport: 'web', url };
|
||||
}
|
||||
|
||||
if (!context.isTauri) {
|
||||
return { transport: 'web', url };
|
||||
}
|
||||
|
||||
const target = new URL(url, `${AGC_DEVELOPMENT_API_BASE_URL}/`);
|
||||
if (target.origin !== AGC_DEVELOPMENT_API_BASE_URL) {
|
||||
throw new Error('AGC release API 请求目标不在允许的开发服务器范围内');
|
||||
if (!context.isTauri || isHttpPage) {
|
||||
return { transport: 'web', url: target.toString() };
|
||||
}
|
||||
return { transport: 'tauri-http', url: target.toString() };
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getStoredAuthAccessToken,
|
||||
refreshClientAuthAccessToken,
|
||||
} from './clientAuth';
|
||||
import { getClientServerBaseUrl } from './clientHttp';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
@@ -62,23 +63,7 @@ async function installCommittedPlatformSession(
|
||||
}
|
||||
|
||||
async function resolvePlatformApiBaseUrl() {
|
||||
if (!import.meta.env.DEV || import.meta.env.MODE === 'test') {
|
||||
return 'https://dev.genarrative.world';
|
||||
}
|
||||
const response = await fetch('/__agc_dev_server.json', {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('无法读取本地陶泥儿 API 服务地址');
|
||||
}
|
||||
const marker = (await response.json()) as { apiTarget?: unknown };
|
||||
const apiBaseUrl =
|
||||
typeof marker.apiTarget === 'string' ? marker.apiTarget.trim() : '';
|
||||
if (!/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(apiBaseUrl)) {
|
||||
throw new Error('本地陶泥儿 API 服务地址不在受信任白名单内');
|
||||
}
|
||||
return apiBaseUrl;
|
||||
return getClientServerBaseUrl();
|
||||
}
|
||||
|
||||
async function commitPlatformSession(
|
||||
|
||||
@@ -86,6 +86,28 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.client-auth-panel select {
|
||||
height: 38px;
|
||||
min-width: 0;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.client-auth-panel select:focus {
|
||||
border-color: #111827;
|
||||
outline: 2px solid rgb(17 24 39 / 10%);
|
||||
}
|
||||
|
||||
.client-auth-server-address {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.client-auth-panel input:focus {
|
||||
border-color: #111827;
|
||||
outline: 2px solid rgb(17 24 39 / 10%);
|
||||
@@ -5728,7 +5750,11 @@ iframe.preview-frame {
|
||||
min-width: max-content;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font:
|
||||
12px/1.55 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
import { resetClientServerSelectionForTests } from '../../src/services/clientHttp';
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
commitAuthenticatedPlatformSession,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
export function registerAuthTests() {
|
||||
afterEach(() => {
|
||||
resetPlatformSessionStateForTests();
|
||||
resetClientServerSelectionForTests();
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
@@ -265,6 +267,39 @@ export function registerAuthTests() {
|
||||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows release, dev, and custom server choices on the login screen', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
if (String(input) === '/api/auth/refresh') {
|
||||
return new Response('', { status: 401 });
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(input)}`);
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
React.createElement(AuthenticatedClient, null, () =>
|
||||
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByRole('main', { name: '登录' });
|
||||
const server = screen.getByRole('combobox', { name: '服务器' });
|
||||
expect(server).not.toBeNull();
|
||||
expect(screen.getByRole('option', { name: 'release' })).not.toBeNull();
|
||||
expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull();
|
||||
expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull();
|
||||
|
||||
fireEvent.change(server, { target: { value: 'custom' } });
|
||||
expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull();
|
||||
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
|
||||
target: { value: 'https://staging.example.com' },
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value,
|
||||
).toBe('https://staging.example.com');
|
||||
});
|
||||
|
||||
it('logs in with a phone code and stores the returned token', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AGC_DEVELOPMENT_API_BASE_URL,
|
||||
AGC_RELEASE_API_BASE_URL,
|
||||
getClientServerBaseUrl,
|
||||
getClientServerSelection,
|
||||
normalizeClientServerBaseUrl,
|
||||
resetClientServerSelectionForTests,
|
||||
resolveClientHttpTarget,
|
||||
setClientServerSelection,
|
||||
} from '../src/services/clientHttp';
|
||||
|
||||
describe('AGC client HTTP transport', () => {
|
||||
afterEach(() => resetClientServerSelectionForTests());
|
||||
|
||||
it('keeps local development requests on the Vite API proxy', () => {
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
@@ -22,10 +30,12 @@ describe('AGC client HTTP transport', () => {
|
||||
isDevelopment: false,
|
||||
isTauri: true,
|
||||
pageProtocol: 'tauri:',
|
||||
mode: 'production',
|
||||
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
|
||||
}),
|
||||
).toEqual({
|
||||
transport: 'tauri-http',
|
||||
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
|
||||
url: `${AGC_RELEASE_API_BASE_URL}/api/auth/me`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +45,7 @@ describe('AGC client HTTP transport', () => {
|
||||
isDevelopment: false,
|
||||
isTauri: false,
|
||||
pageProtocol: 'https:',
|
||||
mode: 'test',
|
||||
}),
|
||||
).toEqual({ transport: 'web', url: '/api/auth/me' });
|
||||
});
|
||||
@@ -45,7 +56,77 @@ describe('AGC client HTTP transport', () => {
|
||||
isDevelopment: false,
|
||||
isTauri: true,
|
||||
pageProtocol: 'tauri:',
|
||||
mode: 'production',
|
||||
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
|
||||
}),
|
||||
).toThrow('不在允许的开发服务器范围内');
|
||||
).toThrow('当前选择的服务器范围');
|
||||
});
|
||||
|
||||
it('persists release, dev, and custom server selection', () => {
|
||||
const release = setClientServerSelection({
|
||||
preset: 'release',
|
||||
customBaseUrl: '',
|
||||
});
|
||||
expect(release).toEqual({
|
||||
preset: 'release',
|
||||
customBaseUrl: '',
|
||||
});
|
||||
expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL);
|
||||
const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
|
||||
expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL);
|
||||
|
||||
const custom = setClientServerSelection({
|
||||
preset: 'custom',
|
||||
customBaseUrl: 'https://staging.example.com/',
|
||||
});
|
||||
expect(custom).toEqual({
|
||||
preset: 'custom',
|
||||
customBaseUrl: 'https://staging.example.com',
|
||||
});
|
||||
expect(getClientServerSelection().preset).toBe('dev');
|
||||
expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com');
|
||||
});
|
||||
|
||||
it('accepts HTTPS custom servers and loopback HTTP only', () => {
|
||||
expect(normalizeClientServerBaseUrl('https://example.com/')).toBe(
|
||||
'https://example.com',
|
||||
);
|
||||
expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe(
|
||||
'http://127.0.0.1:8080',
|
||||
);
|
||||
expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow(
|
||||
'必须使用 HTTPS',
|
||||
);
|
||||
expect(() =>
|
||||
normalizeClientServerBaseUrl('https://example.com/api'),
|
||||
).toThrow('纯 HTTP(S)');
|
||||
});
|
||||
|
||||
it('routes selected custom servers for both web and Tauri clients', () => {
|
||||
const serverBaseUrl = 'https://staging.example.com';
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
isDevelopment: true,
|
||||
isTauri: false,
|
||||
pageProtocol: 'http:',
|
||||
mode: 'development',
|
||||
serverBaseUrl,
|
||||
}),
|
||||
).toEqual({
|
||||
transport: 'web',
|
||||
url: `${serverBaseUrl}/api/auth/me`,
|
||||
});
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
isDevelopment: false,
|
||||
isTauri: true,
|
||||
pageProtocol: 'tauri:',
|
||||
mode: 'production',
|
||||
serverBaseUrl,
|
||||
}),
|
||||
).toEqual({
|
||||
transport: 'tauri-http',
|
||||
url: `${serverBaseUrl}/api/auth/me`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14249,3 +14249,8 @@
|
||||
- 普通客户端必须展示上述安全摘要与建议,不能把可解释的资源恢复失败降级成“执行失败,请稍后重试”。平台账号失效提示重新登录;资源身份冲突、多个同源图集或透明图集明确要求先在资源画布核对,而非盲目重复生成或扣费。
|
||||
- 已有同一画布、身份可信且可解码的规范图与背景图时,历史核心图集的只读恢复只是可选增强:未找到该图集不得阻断直连 Codex 生成、浏览器试玩或版本登记,也不得触发重复付费生成;最终源码仍须实际引用至少一个已登记的平台图片。
|
||||
- 透明后处理失败但平台已保留源图时,只有同一画布身份的只读恢复成功后才清理对应 `agentId/runId` 生成账本;恢复失败、身份不唯一或结果未知继续保留 `accepted` / `operationId` 供对账,禁止因清理过早而重复扣费。
|
||||
|
||||
## 2026-08-18 AGC 登录服务器选择
|
||||
|
||||
- AGC 登录页提供 `release`(`https://www.genarrative.world`)、`dev`(`https://dev.genarrative.world`)和 `custom` 三种服务器选择;选择持久化在客户端本地存储,登录、验证码、刷新和原生平台会话安装统一使用当前选择。
|
||||
- custom 只接受纯 HTTPS origin;开发环境允许 `localhost` / loopback 的 HTTP,禁止把路径、查询参数、凭据或非本机明文 HTTP 地址作为服务器地址。
|
||||
|
||||
Reference in New Issue
Block a user