feat(config): add local startup boundary
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
import { resolveIdentity } from './identity.ts';
|
||||||
|
import { PROFILES } from './profiles.ts';
|
||||||
|
import {
|
||||||
|
resolveRuntimeConfig,
|
||||||
|
type ResolveRuntimeConfigOptions,
|
||||||
|
type RuntimeConfig,
|
||||||
|
} from './runtime-config.ts';
|
||||||
|
|
||||||
|
export interface LocalStartupContext {
|
||||||
|
readonly hostKind: 'h5';
|
||||||
|
readonly profile: 'local';
|
||||||
|
readonly config: RuntimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_CONTROLS = Object.freeze([
|
||||||
|
'profile',
|
||||||
|
'mode',
|
||||||
|
'gameconfig',
|
||||||
|
'agentid',
|
||||||
|
'channelid',
|
||||||
|
'marketid',
|
||||||
|
'version',
|
||||||
|
'gameid',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
function fail(field: string, reason: string): never {
|
||||||
|
throw new Error(`Invalid local startup ${field}: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLocalUrl(url: string, field: string): void {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
fail(field, 'url must be absolute');
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
||||||
|
fail(field, 'url protocol must be ws: or wss:');
|
||||||
|
}
|
||||||
|
if (parsed.username !== '' || parsed.password !== '') {
|
||||||
|
fail(field, 'url credentials are forbidden');
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
if (
|
||||||
|
hostname !== 'localhost'
|
||||||
|
&& hostname !== '[::1]'
|
||||||
|
&& !/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)
|
||||||
|
) {
|
||||||
|
fail(field, 'url hostname must be loopback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLocalQuery(search: string): void {
|
||||||
|
const query = new URLSearchParams(search);
|
||||||
|
for (const field of QUERY_CONTROLS) {
|
||||||
|
if (query.getAll(field).length > 1) fail(field, 'duplicate query control');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = query.getAll('profile');
|
||||||
|
if (profiles.length !== 1 || profiles[0] !== 'local') {
|
||||||
|
fail('profile', 'must occur exactly once with value local');
|
||||||
|
}
|
||||||
|
const modes = query.getAll('mode');
|
||||||
|
if (modes.length === 1 && modes[0] !== 'debug') fail('mode', 'must be debug');
|
||||||
|
const gameconfigs = query.getAll('gameconfig');
|
||||||
|
if (gameconfigs.length === 1 && gameconfigs[0] !== '') {
|
||||||
|
fail('gameconfig', 'must be empty');
|
||||||
|
}
|
||||||
|
if (query.has('gameid')) fail('gameid', 'query override is forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveLocalStartup(
|
||||||
|
options: ResolveRuntimeConfigOptions,
|
||||||
|
): Promise<LocalStartupContext> {
|
||||||
|
if (options.mode !== 'debug') fail('mode', 'must be debug');
|
||||||
|
if (options.hostKind !== 'h5') fail('hostKind', 'must be h5');
|
||||||
|
assertLocalQuery(options.search);
|
||||||
|
|
||||||
|
const config = await resolveRuntimeConfig(options);
|
||||||
|
const context = Object.freeze({ hostKind: 'h5' as const, profile: 'local' as const, config });
|
||||||
|
assertLocalStartupContext(context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLocalStartupContext(context: LocalStartupContext): void {
|
||||||
|
if (context.hostKind !== 'h5') fail('hostKind', 'must be h5');
|
||||||
|
if (context.profile !== 'local') fail('profile', 'must be local');
|
||||||
|
|
||||||
|
const { config } = context;
|
||||||
|
if (config.mode !== 'debug') fail('mode', 'must be debug');
|
||||||
|
if (config.isDebugger !== true) fail('isDebugger', 'must be true');
|
||||||
|
if (config.source !== 'direct') fail('source', 'must be direct');
|
||||||
|
if (config.gameserver !== null) fail('gameserver', 'must be null');
|
||||||
|
if (config.rawConfig !== null) fail('rawConfig', 'must be null');
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolveIdentity([() => config.identity]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) throw error;
|
||||||
|
fail('identity', 'is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authoritative = PROFILES.local?.servers;
|
||||||
|
if (!authoritative || authoritative.length === 0) {
|
||||||
|
fail('servers', 'PROFILES.local must define servers');
|
||||||
|
}
|
||||||
|
if (config.servers.length !== authoritative.length) {
|
||||||
|
fail('servers', 'must equal PROFILES.local.servers');
|
||||||
|
}
|
||||||
|
for (let index = 0; index < authoritative.length; index += 1) {
|
||||||
|
const expected = authoritative[index];
|
||||||
|
validateLocalUrl(expected, `servers[${index}]`);
|
||||||
|
if (config.servers[index] !== expected) {
|
||||||
|
fail('servers', 'must equal PROFILES.local.servers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLocalConnectionTarget(context: LocalStartupContext, url: string): void {
|
||||||
|
assertLocalStartupContext(context);
|
||||||
|
validateLocalUrl(url, 'url');
|
||||||
|
if (!PROFILES.local?.servers?.includes(url)) {
|
||||||
|
fail('url', 'target must occur in PROFILES.local.servers');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assertLocalConnectionTarget,
|
||||||
|
assertLocalStartupContext,
|
||||||
|
resolveLocalStartup,
|
||||||
|
type LocalStartupContext,
|
||||||
|
} from '../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||||
|
import { PROFILES } from '../../YouleNexus/assets/framework/config/profiles.ts';
|
||||||
|
import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||||
|
import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config.ts';
|
||||||
|
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||||
|
|
||||||
|
function options(search = '?profile=local'): ResolveRuntimeConfigOptions & {
|
||||||
|
fetcher: ConfigFetcher & { calls: number };
|
||||||
|
} {
|
||||||
|
const fetcher = {
|
||||||
|
calls: 0,
|
||||||
|
async fetch(): Promise<never> {
|
||||||
|
this.calls += 1;
|
||||||
|
throw new Error('forbidden fetch');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { mode: 'debug', hostKind: 'h5', win: {}, search, fetcher };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('local debug resolves the authoritative direct config without fetching', async () => {
|
||||||
|
const input = options();
|
||||||
|
const context = await resolveLocalStartup(input);
|
||||||
|
|
||||||
|
assert.equal(context.hostKind, 'h5');
|
||||||
|
assert.equal(context.profile, 'local');
|
||||||
|
assert.equal(context.config.identity.gameid, BUILD_IDENTITY.gameid);
|
||||||
|
assert.deepEqual(context.config.servers, PROFILES.local!.servers);
|
||||||
|
assert.equal(input.fetcher.calls, 0);
|
||||||
|
assert.equal(Object.isFrozen(context), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-local startup modes and hosts before fetching', async () => {
|
||||||
|
const cases: Array<[Partial<ResolveRuntimeConfigOptions>, RegExp]> = [
|
||||||
|
[{ mode: 'release' }, /mode.*debug/i],
|
||||||
|
[{ hostKind: 'native-settings' }, /hostKind.*h5/i],
|
||||||
|
[{ hostKind: 'uAgent_3' }, /hostKind.*h5/i],
|
||||||
|
];
|
||||||
|
for (const [override, expected] of cases) {
|
||||||
|
const base = options();
|
||||||
|
const input = { ...base, ...override };
|
||||||
|
await assert.rejects(resolveLocalStartup(input), expected);
|
||||||
|
assert.equal(base.fetcher.calls, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires exactly one local profile and only an optional single debug mode', async () => {
|
||||||
|
const cases: Array<[string, RegExp]> = [
|
||||||
|
['', /profile/i],
|
||||||
|
['?profile=prod', /profile/i],
|
||||||
|
['?profile=staging', /profile/i],
|
||||||
|
['?profile=local&profile=local', /profile/i],
|
||||||
|
['?profile=local&mode=release', /mode.*debug/i],
|
||||||
|
['?profile=local&mode=debug&mode=debug', /mode/i],
|
||||||
|
];
|
||||||
|
for (const [search, expected] of cases) {
|
||||||
|
const input = options(search);
|
||||||
|
await assert.rejects(resolveLocalStartup(input), expected);
|
||||||
|
assert.equal(input.fetcher.calls, 0);
|
||||||
|
}
|
||||||
|
await resolveLocalStartup(options('?profile=local&mode=debug'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects gameconfig and ambiguous identity controls before fetching', async () => {
|
||||||
|
const cases: Array<[string, RegExp]> = [
|
||||||
|
['?profile=local&gameconfig=remote-host', /gameconfig/i],
|
||||||
|
['?profile=local&agentid=A&agentid=B', /agentid/i],
|
||||||
|
['?profile=local&version=1&version=2', /version/i],
|
||||||
|
['?profile=local&gameid=other', /gameid/i],
|
||||||
|
];
|
||||||
|
for (const [search, expected] of cases) {
|
||||||
|
const input = options(search);
|
||||||
|
await assert.rejects(resolveLocalStartup(input), expected);
|
||||||
|
assert.equal(input.fetcher.calls, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('permits query identity overrides except gameid and validates numeric version', async () => {
|
||||||
|
const context = await resolveLocalStartup(options(
|
||||||
|
'?profile=local&agentid=A&channelid=C&marketid=9&version=12',
|
||||||
|
));
|
||||||
|
assert.deepEqual(context.config.identity, {
|
||||||
|
...BUILD_IDENTITY,
|
||||||
|
agentid: 'A',
|
||||||
|
channelid: 'C',
|
||||||
|
marketid: '9',
|
||||||
|
version: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalid = options('?profile=local&version=not-a-number');
|
||||||
|
await assert.rejects(resolveLocalStartup(invalid), /identity.*version.*finite/i);
|
||||||
|
assert.equal(invalid.fetcher.calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function validContext(): LocalStartupContext {
|
||||||
|
return {
|
||||||
|
hostKind: 'h5',
|
||||||
|
profile: 'local',
|
||||||
|
config: {
|
||||||
|
mode: 'debug',
|
||||||
|
isDebugger: true,
|
||||||
|
identity: BUILD_IDENTITY,
|
||||||
|
servers: PROFILES.local!.servers!,
|
||||||
|
source: 'direct',
|
||||||
|
gameserver: null,
|
||||||
|
rawConfig: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('context validator rejects forged config fields and malformed identity', () => {
|
||||||
|
const base = validContext();
|
||||||
|
const configCases: Array<[keyof RuntimeConfig, unknown]> = [
|
||||||
|
['mode', 'release'],
|
||||||
|
['isDebugger', false],
|
||||||
|
['source', 'remote'],
|
||||||
|
['gameserver', 'https://example.com/config.txt'],
|
||||||
|
['rawConfig', {}],
|
||||||
|
['servers', ['ws://127.0.0.1:9999']],
|
||||||
|
['servers', ['https://127.0.0.1:3088']],
|
||||||
|
['servers', ['ws://user:pass@127.0.0.1:3088']],
|
||||||
|
];
|
||||||
|
for (const [field, value] of configCases) {
|
||||||
|
assert.throws(
|
||||||
|
() => assertLocalStartupContext({ ...base, config: { ...base.config, [field]: value } } as LocalStartupContext),
|
||||||
|
new RegExp(field, 'i'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() => assertLocalStartupContext({
|
||||||
|
...base,
|
||||||
|
config: { ...base.config, identity: { ...BUILD_IDENTITY, version: Number.NaN } },
|
||||||
|
}),
|
||||||
|
/identity.*version.*finite/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('connection target must be a valid URL from the authoritative local profile', () => {
|
||||||
|
const context = validContext();
|
||||||
|
assert.doesNotThrow(() => assertLocalConnectionTarget(context, 'ws://127.0.0.1:3088'));
|
||||||
|
for (const [url, expected] of [
|
||||||
|
['ws://127.0.0.1:9999', /url|target|servers/i],
|
||||||
|
['ws://example.com:3088', /url|target|servers|hostname/i],
|
||||||
|
['not-a-url', /url/i],
|
||||||
|
] as const) {
|
||||||
|
assert.throws(() => assertLocalConnectionTarget(context, url), expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user