feat(xhttp): support sessionID* rename + sessionIDTable/Length (xray v26.6.22) (#5506)

* feat(xhttp): support sessionID* rename + sessionIDTable/Length (xray v26.6.22)

xray-core v26.6.22 (PR #6258) renamed the XHTTP session config keys
sessionPlacement/sessionKey to sessionIDPlacement/sessionIDKey (no fallback
kept in core) and added sessionIDTable (predefined charset name or literal
ASCII) and sessionIDLength (range, e.g. 16-32, lower bound > 0).

Panel changes:
- Schema (xhttp.ts): rename the two keys, add sessionIDTable/sessionIDLength,
  and a z.preprocess that lifts legacy keys off stored configs so an upgraded
  panel never silently drops a saved session setting.
- Wire normalize + share-link build/parse: rename keys, emit the two new
  fields, and accept legacy sessionPlacement/sessionKey from old share links.
- Inbound + outbound XHTTP forms: rename field paths, add a sessionIDTable
  autocomplete (9 predefined tables + free ASCII) and a sessionIDLength range
  input shown only when a table is set, with light client validation (ASCII
  table, length min > 0; xray enforces the room-size minimum server-side).
- Subscription (service.go) and Clash (clash_service.go) builders: emit the
  renamed + new keys, with a legacy fallback for not-yet-resaved inbounds.
- Locales: add sessionIDTable/sessionIDLength labels + hints in all 13 files.

Two sibling v26.6.22 XHTTP commits need no panel change and are covered by the
core bump alone: #6332 (XHTTP/3 closes QUIC/UDP) and #6320 (udpHop honors the
existing dialerProxy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(xhttp): add Session ID Table to inbound form-blocks snapshot

The new sessionIDTable input renders by default in the inbound XHTTP form, so
its label joins the field-structure snapshot. sessionIDLength stays conditional
(only shown when a table is set), so it does not appear here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(xhttp): migrate legacy session keys in the running xray config

The Zod preprocess plus the subscription/Clash fallbacks only covered the
panel UI and share-link output. The config handed to the running xray-core
process is built from the raw stored streamSettings in GetXrayConfig, which
did not rewrite the renamed XHTTP session keys — so a pre-upgrade inbound (or
template outbound) stored with a non-default sessionPlacement was emitted
unchanged and dropped by xray-core v26.6.22, until the admin re-saved it.

Lift sessionPlacement/sessionKey onto sessionIDPlacement/sessionIDKey at
config-generation time, in the existing inbound stream-rewrite block (next to
the tls/reality/externalProxy handling) and across template outbounds. The
lift is idempotent and leaves unchanged configs byte-identical so the
hot-reload diff never sees a spurious change.

Also tighten validateSessionIDLength to reject an inverted range (e.g. 32-16)
in addition to the existing lower-bound > 0 check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(xray): avoid summed-capacity allocation in mergeSubscriptionOutbounds

CodeQL go/allocation-size-overflow flagged the pre-sized make() whose
capacity was a sum of three slice lengths. Grow the slice via append on
a nil slice instead; same result, no overflow-prone capacity expression.
This commit is contained in:
Rouzbeh†
2026-06-23 17:38:16 +02:00
committed by GitHub
parent b07fad0e69
commit fea3c94b11
31 changed files with 498 additions and 45 deletions
+4 -2
View File
@@ -64,8 +64,10 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
const stringFields = [
'uplinkHTTPMethod',
'sessionPlacement',
'sessionKey',
'sessionIDPlacement',
'sessionIDKey',
'sessionIDTable',
'sessionIDLength',
'seqPlacement',
'seqKey',
'uplinkDataPlacement',
+23 -3
View File
@@ -24,10 +24,18 @@ type Raw = Record<string, unknown>;
// match the schema's authoring order so diffs read naturally.
const XHTTP_STRING_KEYS = [
'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
'xPaddingMethod', 'sessionPlacement', 'sessionKey', 'seqPlacement',
'seqKey', 'uplinkDataPlacement', 'uplinkDataKey', 'scMaxEachPostBytes',
'scMinPostsIntervalMs', 'scStreamUpServerSecs', 'uplinkHTTPMethod',
'xPaddingMethod', 'sessionIDPlacement', 'sessionIDKey', 'sessionIDTable',
'sessionIDLength', 'seqPlacement', 'seqKey', 'uplinkDataPlacement',
'uplinkDataKey', 'scMaxEachPostBytes', 'scMinPostsIntervalMs',
'scStreamUpServerSecs', 'uplinkHTTPMethod',
] as const;
// Legacy share links (pre xray-core #6258) carry sessionPlacement/sessionKey.
// Map them onto the renamed keys so old links still import. Mirrors the
// schema-level migrateLegacyXhttp.
const XHTTP_LEGACY_ALIASES: Record<string, string> = {
sessionPlacement: 'sessionIDPlacement',
sessionKey: 'sessionIDKey',
};
const XHTTP_NUMBER_KEYS = [
'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
] as const;
@@ -81,12 +89,24 @@ function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = asBool(v);
}
// Fill renamed keys from legacy params only when the new key is absent.
for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
if (xhttp[renamed] === undefined) {
const v = params.get(legacy);
if (v !== null && v !== '') xhttp[renamed] = v;
}
}
}
function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
for (const k of XHTTP_STRING_KEYS) {
if (typeof json[k] === 'string') xhttp[k] = json[k];
}
for (const [legacy, renamed] of Object.entries(XHTTP_LEGACY_ALIASES)) {
if (xhttp[renamed] === undefined && typeof json[legacy] === 'string') {
xhttp[renamed] = json[legacy];
}
}
for (const k of XHTTP_NUMBER_KEYS) {
if (typeof json[k] === 'number') xhttp[k] = json[k];
}
@@ -16,8 +16,10 @@ const PACKET_UP_FIELDS = [
const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
const PLACEMENT_STRING_FIELDS = [
'sessionPlacement',
'sessionKey',
'sessionIDPlacement',
'sessionIDKey',
'sessionIDTable',
'sessionIDLength',
'seqPlacement',
'seqKey',
'uplinkDataPlacement',
+34
View File
@@ -0,0 +1,34 @@
// Client-side validation for xray-core #6258 sessionIDTable / sessionIDLength.
// xray-core also enforces a room-size minimum (sum(table^k for k in
// from..to) >= 2<<30) server-side; we deliberately skip replicating that
// big-int check and only catch the cheap, obvious mistakes here.
// xray-core requires the charset table to be ASCII-only.
export function validateSessionIDTable(_rule: unknown, value: unknown): Promise<void> {
const str = typeof value === 'string' ? value : '';
if (str === '') return Promise.resolve();
// eslint-disable-next-line no-control-regex
if (/[^\x00-\x7f]/.test(str)) {
return Promise.reject(new Error('sessionIDTable must contain only ASCII characters'));
}
return Promise.resolve();
}
// A dash-range like "8-16" or a single "8". The lower bound must be > 0
// (xray rejects sessionIDLength.from <= 0 when a table is set).
export function validateSessionIDLength(_rule: unknown, value: unknown): Promise<void> {
const str = typeof value === 'string' ? value.trim() : '';
if (str === '') return Promise.resolve();
if (!/^\d+(?:-\d+)?$/.test(str)) {
return Promise.reject(new Error('Use a length or range, e.g. 8 or 8-16'));
}
const parts = str.split('-');
const from = Number(parts[0]);
if (!Number.isFinite(from) || from <= 0) {
return Promise.reject(new Error('sessionIDLength minimum must be greater than 0'));
}
if (parts.length === 2 && Number(parts[1]) < from) {
return Promise.reject(new Error('sessionIDLength range upper bound must be ≥ lower bound'));
}
return Promise.resolve();
}
@@ -708,7 +708,7 @@ export default function InboundFormModal({
// etc., not empty strings).
// Seed each network's settings blob with its Zod schema defaults so
// every Form.Item inside the network sub-form has a defined starting
// value. XHTTP in particular has ~20 fields (sessionPlacement,
// value. XHTTP in particular has ~20 fields (sessionIDPlacement,
// seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
// is the literal "" sentinel meaning "let xray-core pick its
// default". Without seeding "", the Form.Item reads `undefined` and
@@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
@@ -11,7 +12,8 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
const { t } = useTranslation();
const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
const xhttpSessionIDPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDPlacement'], form);
const xhttpSessionIDTable = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDTable'], form);
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
@@ -163,7 +165,7 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
</>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
label={t('pages.inbounds.form.sessionPlacement')}
>
<Select
@@ -176,14 +178,36 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
]}
/>
</Form.Item>
{xhttpSessionPlacement && xhttpSessionPlacement !== 'path' && (
{xhttpSessionIDPlacement && xhttpSessionIDPlacement !== 'path' && (
<Form.Item
name={['streamSettings', 'xhttpSettings', 'sessionKey']}
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
label={t('pages.inbounds.form.sessionKey')}
>
<Input placeholder="x_session" />
</Form.Item>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
label={t('pages.inbounds.form.sessionIDTable')}
tooltip={t('pages.inbounds.form.sessionIDTableHint')}
rules={[{ validator: validateSessionIDTable }]}
>
<AutoComplete
allowClear
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
placeholder="Base62"
/>
</Form.Item>
{xhttpSessionIDTable && (
<Form.Item
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
label={t('pages.inbounds.form.sessionIDLength')}
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
rules={[{ validator: validateSessionIDLength }]}
>
<Input placeholder="8-16" />
</Form.Item>
)}
<Form.Item
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
label={t('pages.inbounds.form.sequencePlacement')}
@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
import { MODE_OPTIONS } from '../outbound-form-constants';
@@ -145,7 +147,7 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
only matters when placement is not 'path'. */}
<Form.Item
label={t('pages.inbounds.form.sessionPlacement')}
name={['streamSettings', 'xhttpSettings', 'sessionPlacement']}
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
>
<Select
placeholder="Default (path)"
@@ -161,19 +163,49 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
<Form.Item shouldUpdate noStyle>
{() => {
const placement = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'sessionPlacement',
'streamSettings', 'xhttpSettings', 'sessionIDPlacement',
]);
if (!placement || placement === 'path') return null;
return (
<Form.Item
label={t('pages.inbounds.form.sessionKey')}
name={['streamSettings', 'xhttpSettings', 'sessionKey']}
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
>
<Input placeholder="x_session" />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.sessionIDTable')}
tooltip={t('pages.inbounds.form.sessionIDTableHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
rules={[{ validator: validateSessionIDTable }]}
>
<AutoComplete
allowClear
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
placeholder="Base62"
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
const table = form.getFieldValue([
'streamSettings', 'xhttpSettings', 'sessionIDTable',
]);
if (!table) return null;
return (
<Form.Item
label={t('pages.inbounds.form.sessionIDLength')}
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
rules={[{ validator: validateSessionIDLength }]}
>
<Input placeholder="8-16" />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.sequencePlacement')}
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
+36 -4
View File
@@ -25,7 +25,34 @@ export const XHttpXmuxSchema = z.object({
});
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
export const XHttpStreamSettingsSchema = z.object({
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
// charset (splithttp.PredefinedTable, xray-core #6258). A literal ASCII
// charset string is also accepted.
export const XHTTP_SESSION_ID_TABLES = [
'ALPHABET', 'Alphabet', 'BASE36', 'Base62', 'HEX',
'alphabet', 'base36', 'hex', 'number',
] as const;
// xray-core #6258 renamed sessionPlacement/sessionKey to
// sessionIDPlacement/sessionIDKey (no fallback kept in core) and added
// sessionIDTable/sessionIDLength. Lift any legacy keys persisted by an older
// panel onto the new names so a saved inbound/outbound never silently loses
// its session setting, then drop the legacy keys so we never emit both.
function migrateLegacyXhttp(v: unknown): unknown {
if (v == null || typeof v !== 'object' || Array.isArray(v)) return v;
const o = { ...(v as Record<string, unknown>) };
if (o.sessionIDPlacement === undefined && o.sessionPlacement !== undefined) {
o.sessionIDPlacement = o.sessionPlacement;
}
if (o.sessionIDKey === undefined && o.sessionKey !== undefined) {
o.sessionIDKey = o.sessionKey;
}
delete o.sessionPlacement;
delete o.sessionKey;
return o;
}
export const XHttpStreamSettingsSchema = z.preprocess(migrateLegacyXhttp, z.object({
path: z.string().default('/'),
host: z.string().default(''),
mode: XHttpModeSchema.default('auto'),
@@ -35,8 +62,13 @@ export const XHttpStreamSettingsSchema = z.object({
xPaddingHeader: z.string().default(''),
xPaddingPlacement: z.string().default(''),
xPaddingMethod: z.string().default(''),
sessionPlacement: z.string().default(''),
sessionKey: z.string().default(''),
sessionIDPlacement: z.string().default(''),
sessionIDKey: z.string().default(''),
// sessionIDTable: a predefined name (XHTTP_SESSION_ID_TABLES) or a literal
// ASCII charset. sessionIDLength: dash-range string (e.g. '8-16'); only
// honored when a table is set. xray-core enforces the room-size minimum.
sessionIDTable: z.string().default(''),
sessionIDLength: z.string().default(''),
seqPlacement: z.string().default(''),
seqKey: z.string().default(''),
uplinkDataPlacement: z.string().default(''),
@@ -65,5 +97,5 @@ export const XHttpStreamSettingsSchema = z.object({
// Never present on the wire — outbound modal strips it via the
// form-to-wire adapter.
enableXmux: z.boolean().default(false),
});
}));
export type XHttpStreamSettings = z.infer<typeof XHttpStreamSettingsSchema>;
@@ -118,6 +118,7 @@ exports[`inbound transport forms > XhttpForm field structure is stable 1`] = `
"Uplink HTTP Method",
"Padding Obfs Mode",
"Session Placement",
"Session ID Table",
"Sequence Placement",
"No SSE Header",
"XMUX",
@@ -53,8 +53,10 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = `
"seqKey": "",
"seqPlacement": "",
"serverMaxHeaderBytes": 0,
"sessionKey": "",
"sessionPlacement": "",
"sessionIDKey": "",
"sessionIDLength": "",
"sessionIDPlacement": "",
"sessionIDTable": "",
"uplinkChunkSize": 0,
"uplinkDataKey": "",
"uplinkDataPlacement": "",
@@ -87,8 +89,10 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
"seqKey": "",
"seqPlacement": "",
"serverMaxHeaderBytes": 0,
"sessionKey": "",
"sessionPlacement": "",
"sessionIDKey": "",
"sessionIDLength": "",
"sessionIDPlacement": "",
"sessionIDTable": "",
"uplinkChunkSize": 0,
"uplinkDataKey": "",
"uplinkDataPlacement": "",
@@ -121,8 +125,10 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"seqKey": "X-Seq",
"seqPlacement": "cookie",
"serverMaxHeaderBytes": 0,
"sessionKey": "X-Session",
"sessionPlacement": "header",
"sessionIDKey": "X-Session",
"sessionIDLength": "",
"sessionIDPlacement": "header",
"sessionIDTable": "",
"uplinkChunkSize": 0,
"uplinkDataKey": "u",
"uplinkDataPlacement": "query",
@@ -158,8 +164,10 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably
"seqKey": "",
"seqPlacement": "",
"serverMaxHeaderBytes": 16384,
"sessionKey": "",
"sessionPlacement": "",
"sessionIDKey": "",
"sessionIDLength": "",
"sessionIDPlacement": "",
"sessionIDTable": "",
"uplinkChunkSize": 8192,
"uplinkDataKey": "",
"uplinkDataPlacement": "",
@@ -4,8 +4,8 @@
"path": "/sp",
"host": "edge.example.test",
"mode": "auto",
"sessionPlacement": "header",
"sessionKey": "X-Session",
"sessionIDPlacement": "header",
"sessionIDKey": "X-Session",
"seqPlacement": "cookie",
"seqKey": "X-Seq",
"uplinkDataPlacement": "query",
@@ -93,6 +93,7 @@ describe('parseVmessLink — XHTTP advanced fields', () => {
scMaxBufferedPosts: 50,
tls: 'tls',
};
// legacy sessionKey must alias onto the renamed sessionIDKey (#6258)
const link = `vmess://${Base64.encode(JSON.stringify(json))}`;
const out = parseVmessLink(link);
const xhttp = (out?.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
@@ -101,7 +102,8 @@ describe('parseVmessLink — XHTTP advanced fields', () => {
expect(xhttp.xPaddingHeader).toBe('X-Pad');
expect(xhttp.xPaddingPlacement).toBe('header');
expect(xhttp.xPaddingMethod).toBe('random');
expect(xhttp.sessionKey).toBe('X-Session');
expect(xhttp.sessionIDKey).toBe('X-Session');
expect(xhttp.sessionKey).toBeUndefined();
expect(xhttp.seqKey).toBe('X-Seq');
expect(xhttp.noSSEHeader).toBe(true);
expect(xhttp.scMaxBufferedPosts).toBe(50);
@@ -135,7 +137,8 @@ describe('parseVlessLink — XHTTP advanced fields', () => {
+ '?type=xhttp&security=tls&host=edge.example&path=%2Fsp'
+ '&xPaddingObfsMode=true&xPaddingKey=secret-key&xPaddingHeader=X-Pad'
+ '&xPaddingPlacement=header&xPaddingMethod=random'
+ '&sessionKey=X-Session&seqKey=X-Seq&noSSEHeader=true'
+ '&sessionIDKey=X-Session&sessionIDTable=Base62&sessionIDLength=16-32'
+ '&seqKey=X-Seq&noSSEHeader=true'
+ '&scMaxBufferedPosts=50'
+ '#imported-pad';
const out = parseVlessLink(link);
@@ -145,7 +148,9 @@ describe('parseVlessLink — XHTTP advanced fields', () => {
expect(xhttp.xPaddingHeader).toBe('X-Pad');
expect(xhttp.xPaddingPlacement).toBe('header');
expect(xhttp.xPaddingMethod).toBe('random');
expect(xhttp.sessionKey).toBe('X-Session');
expect(xhttp.sessionIDKey).toBe('X-Session');
expect(xhttp.sessionIDTable).toBe('Base62');
expect(xhttp.sessionIDLength).toBe('16-32');
expect(xhttp.seqKey).toBe('X-Seq');
expect(xhttp.noSSEHeader).toBe(true);
expect(xhttp.scMaxBufferedPosts).toBe(50);
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
// xray-core #6258: sessionPlacement/sessionKey were renamed to
// sessionIDPlacement/sessionIDKey. The schema must lift legacy keys off
// stored configs so an upgraded panel never silently drops them.
describe('XHttpStreamSettingsSchema legacy migration', () => {
it('lifts legacy sessionPlacement/sessionKey onto the renamed keys', () => {
const parsed = XHttpStreamSettingsSchema.parse({
sessionPlacement: 'cookie',
sessionKey: 'x_session',
});
expect(parsed.sessionIDPlacement).toBe('cookie');
expect(parsed.sessionIDKey).toBe('x_session');
// legacy keys must not survive — we never emit both names
expect((parsed as Record<string, unknown>).sessionPlacement).toBeUndefined();
expect((parsed as Record<string, unknown>).sessionKey).toBeUndefined();
});
it('prefers an explicit new key over a legacy one', () => {
const parsed = XHttpStreamSettingsSchema.parse({
sessionPlacement: 'cookie',
sessionIDPlacement: 'header',
});
expect(parsed.sessionIDPlacement).toBe('header');
});
it('defaults the new fields to empty', () => {
const parsed = XHttpStreamSettingsSchema.parse({});
expect(parsed.sessionIDTable).toBe('');
expect(parsed.sessionIDLength).toBe('');
});
});
describe('sessionID validators', () => {
it('accepts empty and ASCII tables, rejects non-ASCII', async () => {
await expect(validateSessionIDTable(null, '')).resolves.toBeUndefined();
await expect(validateSessionIDTable(null, 'Base62')).resolves.toBeUndefined();
await expect(validateSessionIDTable(null, 'ABCdef0123')).resolves.toBeUndefined();
await expect(validateSessionIDTable(null, ' café')).rejects.toThrow();
});
it('accepts a positive length/range, rejects zero or junk', async () => {
await expect(validateSessionIDLength(null, '')).resolves.toBeUndefined();
await expect(validateSessionIDLength(null, '8')).resolves.toBeUndefined();
await expect(validateSessionIDLength(null, '16-32')).resolves.toBeUndefined();
await expect(validateSessionIDLength(null, '8-8')).resolves.toBeUndefined();
await expect(validateSessionIDLength(null, '0-16')).rejects.toThrow();
await expect(validateSessionIDLength(null, '32-16')).rejects.toThrow();
await expect(validateSessionIDLength(null, 'abc')).rejects.toThrow();
});
});