Restore New API session compatibility across user-id headers

New API-compatible deployments disagree on which user-id header session-cookie calls must send, so Metapi should emit the compatible header set wherever it has a resolved platform user id.

Constraint: Some New API deployments require X-User-Id while existing integrations already depend on New-Api-User variants.
Rejected: Replacing the existing header variants | it would regress deployments that already accept the current names.
Confidence: high
Scope-risk: narrow
Directive: Keep New API session-cookie auth header changes centralized in the shared helper.
Tested: GitHub CI for PR #567 passed Test Core, Build Web, Build Server, Build Desktop, Typecheck, schema checks for SQLite/MySQL/Postgres, CodeQL, and audit.
Not-tested: No fresh post-merge local runtime smoke test was run.
This commit is contained in:
Brisbanehuang
2026-06-24 10:05:28 +08:00
committed by GitHub
parent e72d19e235
commit f396014728
2 changed files with 72 additions and 9 deletions
@@ -14,6 +14,7 @@ interface RequestSnapshot {
const COOKIE_SESSION_TOKEN = 'cookie-session-token';
const COOKIE_REQUIRES_USER_TOKEN = 'cookie-requires-user';
const COOKIE_REQUIRES_X_USER_ID_TOKEN = 'cookie-requires-x-user-id';
const CHECKIN_ALREADY_TOKEN = 'checkin-already-token';
const CHECKIN_INVALID_URL_TOKEN = 'checkin-invalid-url-token';
const CHECKIN_INVALID_URL_EXPIRED_SESSION_TOKEN = 'checkin-invalid-url-expired-session-token';
@@ -222,6 +223,11 @@ describe('NewApiAdapter', () => {
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
return;
}
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_REQUIRES_X_USER_ID_TOKEN}`) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
return;
}
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_SESSION_TOKEN}`)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -248,6 +254,21 @@ describe('NewApiAdapter', () => {
return;
}
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_REQUIRES_X_USER_ID_TOKEN}`)) {
if (req.headers['x-user-id'] !== '448') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'missing X-User-Id' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: {
items: [{ key: 'cookie-x-user-id-key' }],
},
}));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: {
@@ -340,6 +361,11 @@ describe('NewApiAdapter', () => {
res.end(JSON.stringify({ success: false, message: 'invalid token' }));
return;
}
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_REQUIRES_X_USER_ID_TOKEN}`) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'invalid token' }));
return;
}
if (typeof req.headers.authorization === 'string') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'invalid token' }));
@@ -369,6 +395,20 @@ describe('NewApiAdapter', () => {
return;
}
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_REQUIRES_X_USER_ID_TOKEN}`)) {
if (req.headers['x-user-id'] !== '448') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'missing X-User-Id' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: { id: 448, username: 'x-user-id-cookie-user', quota: 1500000, used_quota: 100000 },
}));
return;
}
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_GOB_USER_TOKEN}`)) {
if (req.headers['new-api-user'] !== '144408') {
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -625,6 +665,21 @@ describe('NewApiAdapter', () => {
).toBe(true);
});
it('sends X-User-Id for cookie sessions when the site requires that New API variant', async () => {
const adapter = new NewApiAdapter();
const result = await adapter.verifyToken(baseUrl, COOKIE_REQUIRES_X_USER_ID_TOKEN, 448);
expect(result.tokenType).toBe('session');
expect(result.userInfo?.username).toBe('x-user-id-cookie-user');
expect(result.apiToken).toBe('cookie-x-user-id-key');
expect(
requests.some((r) => r.url === '/api/user/self' && r.headers['x-user-id'] === '448'),
).toBe(true);
expect(
requests.some((r) => r.url?.startsWith('/api/token/') && r.headers['x-user-id'] === '448'),
).toBe(true);
});
it('solves anyrouter acw challenge and probes user id from session payload', async () => {
const adapter = new NewApiAdapter();
const result = await adapter.verifyToken(baseUrl, COOKIE_SHIELDED_TOKEN);
+17 -9
View File
@@ -51,13 +51,21 @@ export class NewApiAdapter extends BasePlatformAdapter {
}
private authHeaders(accessToken: string, userId?: number): Record<string, string> {
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
return {
Authorization: `Bearer ${accessToken}`,
...this.userIdHeaders(userId),
};
}
private userIdHeaders(userId?: number | null): Record<string, string> {
const headers: Record<string, string> = {};
if (userId) {
const value = String(userId);
headers['New-API-User'] = value;
headers['Veloera-User'] = value;
headers['voapi-user'] = value;
headers['User-id'] = value;
headers['X-User-Id'] = value;
headers['Rix-Api-User'] = value;
headers['neo-api-user'] = value;
}
@@ -769,7 +777,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
for (const cookie of this.buildCookieCandidates(token)) {
try {
const headers: Record<string, string> = { Cookie: cookie };
if (platformUserId) headers['New-Api-User'] = String(platformUserId);
Object.assign(headers, this.userIdHeaders(platformUserId));
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/self`, { headers });
if (res?.success && res?.data) return res;
if (typeof res?.message === 'string' && res.message.trim()) {
@@ -786,7 +794,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
for (const id of candidates) {
try {
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/self`, {
headers: { Cookie: cookie, 'New-Api-User': String(id) },
headers: { Cookie: cookie, ...this.userIdHeaders(id) },
});
if (res?.success && res?.data) return id;
} catch {}
@@ -812,7 +820,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
for (const cookie of this.buildCookieCandidates(token)) {
try {
const headers: Record<string, string> = { Cookie: cookie };
if (userId) headers['New-Api-User'] = String(userId);
Object.assign(headers, this.userIdHeaders(userId));
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/token/?p=0&size=100`, { headers });
const normalized = this.normalizeTokenItems(this.parseTokenItems(res));
if (normalized.length > 0) return normalized;
@@ -825,7 +833,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
for (const cookie of this.buildCookieCandidates(token)) {
try {
const headers: Record<string, string> = { Cookie: cookie };
if (userId) headers['New-Api-User'] = String(userId);
Object.assign(headers, this.userIdHeaders(userId));
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/models`, { headers });
if (Array.isArray(res?.data) && res.data.length > 0) return res.data.filter(Boolean);
if (res?.data && typeof res.data === 'object') {
@@ -1125,7 +1133,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
try {
const headers: Record<string, string> = { Cookie: cookie };
if (cookieUserId) headers['New-Api-User'] = String(cookieUserId);
Object.assign(headers, this.userIdHeaders(cookieUserId));
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/checkin`, {
method: 'POST',
headers,
@@ -1284,7 +1292,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
for (const cookie of this.buildCookieCandidates(accessToken)) {
try {
const headers: Record<string, string> = { Cookie: cookie };
if (cookieUserId) headers['New-Api-User'] = String(cookieUserId);
Object.assign(headers, this.userIdHeaders(cookieUserId));
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/token/`, {
method: 'POST',
headers,
@@ -1327,7 +1335,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
const cookieUserId = resolvedUserId || await this.probeUserIdByCookie(baseUrl, accessToken);
for (const cookie of this.buildCookieCandidates(accessToken)) {
const headers: Record<string, string> = { Cookie: cookie };
if (cookieUserId) headers['New-Api-User'] = String(cookieUserId);
Object.assign(headers, this.userIdHeaders(cookieUserId));
try {
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/self/groups`, { headers });
@@ -1395,7 +1403,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
const cookieUserId = resolvedUserId || await this.probeUserIdByCookie(baseUrl, accessToken);
for (const cookie of this.buildCookieCandidates(accessToken)) {
const headers: Record<string, string> = { Cookie: cookie };
if (cookieUserId) headers['New-Api-User'] = String(cookieUserId);
Object.assign(headers, this.userIdHeaders(cookieUserId));
try {
if (!tokenId) {