feat(config): add scoped local visitor accounts
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
|
||||
|
||||
export interface LocalStoragePort {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export const LOCAL_MACHINE_KEY = 'youle.local-test.machine.v1';
|
||||
|
||||
export function localAccountKey(context: LocalStartupContext): string {
|
||||
assertLocalStartupContext(context);
|
||||
const { identity } = context.config;
|
||||
const scope = JSON.stringify([
|
||||
context.profile,
|
||||
context.config.servers,
|
||||
identity.agentid,
|
||||
identity.gameid,
|
||||
identity.channelid,
|
||||
identity.marketid,
|
||||
]);
|
||||
return `youle.local-test.account.v1:${scope}`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { LoginAccountIdentity } from '../../protocol/contracts/index.ts';
|
||||
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
|
||||
import { localAccountKey, type LocalStoragePort } from './local-storage.ts';
|
||||
|
||||
const AVATARS = Object.freeze([
|
||||
'https://img2.baidu.com/it/u=1823726962,2692568839&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img0.baidu.com/it/u=320875415,2231067789&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=3407774307,2859661062&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img2.baidu.com/it/u=1161398577,3592656327&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=2061657615,4194426181&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=501',
|
||||
'https://img2.baidu.com/it/u=3081347414,2363887197&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=1364384963,3982203144&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=3041194995,2386060237&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img2.baidu.com/it/u=2904976445,2884058010&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
|
||||
'https://img0.baidu.com/it/u=1801052125,1596537112&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
|
||||
] as const);
|
||||
|
||||
export interface LocalVisitorProvider {
|
||||
getAccount(): Promise<LoginAccountIdentity>;
|
||||
resetAccount(): Promise<LoginAccountIdentity>;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly context: LocalStartupContext;
|
||||
readonly storage: LocalStoragePort;
|
||||
readonly random: () => number;
|
||||
readonly isSessionActive: () => boolean;
|
||||
}
|
||||
|
||||
const ACCOUNT_FIELDS = Object.freeze(['openid', 'nickname', 'avatar', 'sex', 'province', 'city', 'unionid'] as const);
|
||||
|
||||
function wrap(key: string, path: string, reason: string, cause?: unknown): Error {
|
||||
const error = new Error(`Invalid local account cache ${key} at ${path}: ${reason}`) as Error & { cause?: unknown };
|
||||
if (cause !== undefined) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function exactRecord(value: unknown, key: string, path: string, fields: readonly string[]): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw wrap(key, path, 'expected object');
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const field of fields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, field)) throw wrap(key, `${path}.${field}`, 'missing field');
|
||||
}
|
||||
for (const field of Object.keys(record)) if (!fields.includes(field)) throw wrap(key, `${path}.${field}`, 'unknown field');
|
||||
return record;
|
||||
}
|
||||
|
||||
function validateAccount(value: unknown, key: string): LoginAccountIdentity {
|
||||
const account = exactRecord(value, key, 'account', ACCOUNT_FIELDS);
|
||||
for (const field of ['openid', 'nickname', 'avatar', 'province', 'city', 'unionid'] as const) {
|
||||
if (typeof account[field] !== 'string') throw wrap(key, `account.${field}`, 'expected string');
|
||||
}
|
||||
if (account.openid === '') throw wrap(key, 'account.openid', 'must not be empty');
|
||||
if (account.unionid === '') throw wrap(key, 'account.unionid', 'must not be empty');
|
||||
if (!Number.isInteger(account.sex)) throw wrap(key, 'account.sex', 'expected integer');
|
||||
if (account.sex !== 1 && account.sex !== 2) throw wrap(key, 'account.sex', 'expected 1 or 2');
|
||||
if (account.nickname === '' && account.avatar === '') throw wrap(key, 'account.nickname/avatar', 'must not both be empty');
|
||||
return Object.freeze(account as unknown as LoginAccountIdentity);
|
||||
}
|
||||
|
||||
function parseCached(raw: string, key: string): LoginAccountIdentity {
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(raw); } catch (cause) { throw wrap(key, '$', 'invalid JSON', cause); }
|
||||
const record = exactRecord(value, key, '$', ['schemaVersion', 'account']);
|
||||
if (record.schemaVersion !== 1) throw wrap(key, 'schemaVersion', 'expected 1');
|
||||
return validateAccount(record.account, key);
|
||||
}
|
||||
|
||||
function sample(random: () => number, key: string): number {
|
||||
let value: number;
|
||||
try { value = random(); } catch (cause) { throw wrap(key, 'random', 'source failed', cause); }
|
||||
if (!Number.isFinite(value)) throw wrap(key, 'random', 'expected finite number');
|
||||
if (value < 0 || value >= 1) throw wrap(key, 'random', 'expected range [0, 1)');
|
||||
return value;
|
||||
}
|
||||
|
||||
function generate(random: () => number, key: string): LoginAccountIdentity {
|
||||
const id = Math.floor(sample(random, key) * 100000);
|
||||
const avatar = AVATARS[Math.floor(sample(random, key) * AVATARS.length)]!;
|
||||
const sex = Math.floor(sample(random, key) * 2) + 1;
|
||||
return validateAccount({ openid: `testopenid_${id}`, unionid: `ylgame${id}`,
|
||||
nickname: `${id}_游客`, avatar, sex, province: 'jiangxi', city: 'nanchang' }, key);
|
||||
}
|
||||
|
||||
function storageFailure(key: string, operation: 'read' | 'write', cause: unknown): Error {
|
||||
return wrap(key, '$', `storage ${operation} failed`, cause);
|
||||
}
|
||||
|
||||
export function createLocalVisitorProvider(options: Options): LocalVisitorProvider {
|
||||
assertLocalStartupContext(options.context);
|
||||
const key = localAccountKey(options.context);
|
||||
let pending: Promise<LoginAccountIdentity> | null = null;
|
||||
|
||||
async function getAccount(): Promise<LoginAccountIdentity> {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
let raw: string | null;
|
||||
try { raw = options.storage.getItem(key); } catch (cause) { throw storageFailure(key, 'read', cause); }
|
||||
if (raw !== null) return parseCached(raw, key);
|
||||
const account = generate(options.random, key);
|
||||
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
|
||||
catch (cause) { throw storageFailure(key, 'write', cause); }
|
||||
return account;
|
||||
})();
|
||||
try { return await pending; } finally { pending = null; }
|
||||
}
|
||||
|
||||
async function resetAccount(): Promise<LoginAccountIdentity> {
|
||||
if (options.isSessionActive()) throw new Error('Cannot reset local visitor account during an active session');
|
||||
if (pending) throw new Error('Cannot reset local visitor account while getAccount is pending');
|
||||
const account = generate(options.random, key);
|
||||
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
|
||||
catch (cause) { throw storageFailure(key, 'write', cause); }
|
||||
return account;
|
||||
}
|
||||
|
||||
return Object.freeze({ getAccount, resetAccount });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||
import { PROFILES } from '../../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import type { LocalStartupContext } from '../../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
import { LOCAL_MACHINE_KEY, localAccountKey, type LocalStoragePort } from '../../../YouleNexus/assets/framework/adapters/local/local-storage.ts';
|
||||
import { createLocalVisitorProvider } from '../../../YouleNexus/assets/framework/adapters/local/visitor-account.ts';
|
||||
|
||||
class MapStorage implements LocalStoragePort {
|
||||
readonly values = new Map<string, string>();
|
||||
readonly writes: Array<[string, string]> = [];
|
||||
getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
setItem(key: string, value: string): void { this.writes.push([key, value]); this.values.set(key, value); }
|
||||
}
|
||||
|
||||
function context(overrides: Partial<typeof BUILD_IDENTITY> = {}): LocalStartupContext {
|
||||
return { hostKind: 'h5', profile: 'local', config: {
|
||||
mode: 'debug', isDebugger: true, source: 'direct', gameserver: null, rawConfig: null,
|
||||
servers: PROFILES.local!.servers!, identity: { ...BUILD_IDENTITY, ...overrides },
|
||||
} };
|
||||
}
|
||||
|
||||
test('concurrent account reads share one generated frozen identity and one write', async () => {
|
||||
const storage = new MapStorage();
|
||||
let randomCalls = 0;
|
||||
const provider = createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => { randomCalls += 1; return 0.5; }, isSessionActive: () => false });
|
||||
const [first, second] = await Promise.all([provider.getAccount(), provider.getAccount()]);
|
||||
assert.strictEqual(first, second);
|
||||
assert.deepEqual(first, { openid: 'testopenid_50000', unionid: 'ylgame50000', nickname: '50000_游客',
|
||||
avatar: 'https://img2.baidu.com/it/u=3081347414,2363887197&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
sex: 2, province: 'jiangxi', city: 'nanchang' });
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.equal(randomCalls, 3);
|
||||
assert.equal(storage.writes.length, 1);
|
||||
});
|
||||
|
||||
test('random boundaries select first and last valid values', async () => {
|
||||
for (const [samples, expected] of [
|
||||
[[0, 0, 0], ['testopenid_0', 1, 'u=1823726962,2692568839']],
|
||||
[[0.999999, 0.999999, 0.999999], ['testopenid_99999', 2, 'u=1801052125,1596537112']],
|
||||
] as const) {
|
||||
const queue = [...samples];
|
||||
const account = await createLocalVisitorProvider({ context: context(), storage: new MapStorage(),
|
||||
random: () => queue.shift()!, isSessionActive: () => false }).getAccount();
|
||||
assert.equal(account.openid, expected[0]);
|
||||
assert.equal(account.sex, expected[1]);
|
||||
assert.match(account.avatar, new RegExp(expected[2]));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects every invalid random sample without writing', async () => {
|
||||
for (const invalid of [Number.NaN, Number.POSITIVE_INFINITY, -0.01, 1]) {
|
||||
const storage = new MapStorage();
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => invalid, isSessionActive: () => false }).getAccount(), /random.*finite|random.*range/i);
|
||||
assert.equal(storage.writes.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('reuses only an exact schema record and never randomizes it', async () => {
|
||||
const storage = new MapStorage();
|
||||
const account = { openid: 'cached', nickname: '', avatar: 'avatar', sex: 1, province: '', city: '', unionid: 'u' };
|
||||
storage.values.set(localAccountKey(context()), JSON.stringify({ schemaVersion: 1, account }));
|
||||
const loaded = await createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => { throw new Error('random forbidden'); }, isSessionActive: () => false }).getAccount();
|
||||
assert.deepEqual(loaded, account);
|
||||
assert.equal(Object.isFrozen(loaded), true);
|
||||
assert.equal(storage.writes.length, 0);
|
||||
});
|
||||
|
||||
test('rejects malformed cached records with key and field path without rewriting or leaking text', async () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['{secret-account-text', '$'],
|
||||
[JSON.stringify({ schemaVersion: 2, account: {} }), 'schemaVersion'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x' } }), 'account.nickname'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x', nickname: '', avatar: '', sex: 1, province: '', city: '', unionid: 'u' } }), 'nickname'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x', nickname: 'n', avatar: '', sex: 1, province: '', city: '', unionid: 'u', headimgurl: 'old' } }), 'headimgurl'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x', nickname: 'n', avatar: '', sex: 1, province: '', city: '', unionid: 'u', Province: 'old' } }), 'Province'],
|
||||
];
|
||||
for (const [raw, path] of cases) {
|
||||
const storage = new MapStorage();
|
||||
const key = localAccountKey(context());
|
||||
storage.values.set(key, raw);
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => 0, isSessionActive: () => false }).getAccount(), (error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, new RegExp(path.replace('.', '\\.')));
|
||||
assert.match(error.message, /youle\.local-test\.account\.v1:/);
|
||||
assert.doesNotMatch(error.message, /secret-account-text/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(storage.writes.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves dependency failures as causes and keeps old storage on generation failure', async () => {
|
||||
const readFailure = new Error('read broke');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(),
|
||||
storage: { getItem() { throw readFailure; }, setItem() {} }, random: () => 0, isSessionActive: () => false,
|
||||
}).getAccount(), (error: unknown) => error instanceof Error && (error as Error & { cause?: unknown }).cause === readFailure);
|
||||
const storage = new MapStorage();
|
||||
const key = localAccountKey(context());
|
||||
storage.values.set(key, 'old-value');
|
||||
const failure = new Error('generation broke');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => { throw failure; }, isSessionActive: () => false }).resetAccount(),
|
||||
(error: unknown) => error instanceof Error && (error as Error & { cause?: unknown }).cause === failure);
|
||||
assert.equal(storage.values.get(key), 'old-value');
|
||||
|
||||
const writeFailure = new Error('write broke');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(),
|
||||
storage: { getItem() { return null; }, setItem() { throw writeFailure; } },
|
||||
random: () => 0, isSessionActive: () => false,
|
||||
}).getAccount(), (error: unknown) => error instanceof Error && (error as Error & { cause?: unknown }).cause === writeFailure);
|
||||
});
|
||||
|
||||
test('scope isolates identity types, ignores version, and serializes the server configuration', () => {
|
||||
const base = context({ marketid: 7, version: 1 });
|
||||
assert.notEqual(localAccountKey(base), localAccountKey(context({ marketid: '7', version: 1 })));
|
||||
assert.equal(localAccountKey(base), localAccountKey(context({ marketid: 7, version: 999 })));
|
||||
assert.equal(localAccountKey(base),
|
||||
'youle.local-test.account.v1:["local",["ws://127.0.0.1:3088"],"veRa0qrBf0df2K1G4de2tgfmVxB2jxpv","J9r4022t80pxlb01zOK76DN1WD4QewFl","FtJf073aa0d6rI1xD8J1Y42fINTm0ziK",7]');
|
||||
});
|
||||
|
||||
test('reset is isolated, refuses active sessions, and refuses overlap with getAccount', async () => {
|
||||
const storage = new MapStorage();
|
||||
storage.values.set('visitorinfo', 'legacy');
|
||||
storage.values.set(LOCAL_MACHINE_KEY, 'machine');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => 0, isSessionActive: () => true }).resetAccount(), /active session/i);
|
||||
assert.equal(storage.writes.length, 0);
|
||||
const provider = createLocalVisitorProvider({ context: context(), storage: new MapStorage(),
|
||||
random: () => 0, isSessionActive: () => false });
|
||||
const pending = provider.getAccount();
|
||||
await assert.rejects(provider.resetAccount(), /pending|getAccount/i);
|
||||
await pending;
|
||||
const reset = await createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => 0, isSessionActive: () => false }).resetAccount();
|
||||
assert.equal(reset.openid, 'testopenid_0');
|
||||
assert.deepEqual(storage.writes.map(([key]) => key), [localAccountKey(context())]);
|
||||
assert.equal(storage.values.get('visitorinfo'), 'legacy');
|
||||
assert.equal(storage.values.get(LOCAL_MACHINE_KEY), 'machine');
|
||||
});
|
||||
Reference in New Issue
Block a user