feat: lift 5-account cap on HTTP/2

This commit is contained in:
Linus Rath
2026-05-07 12:28:33 +02:00
parent bd72dec98f
commit 5f464d4ee2
12 changed files with 77 additions and 31 deletions
+40 -2
View File
@@ -62,5 +62,43 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
return `${baseKey}::${accountId}`;
}
/** Maximum number of accounts allowed */
export const MAX_ACCOUNTS = 5;
/**
* Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies
* (session, refresh token, server id, auth context), so 50 slots ≈ 125
* cookies on average — within Firefox's per-domain limit of 150.
*/
export const MAX_ACCOUNT_SLOTS = 50;
/**
* UX cap for browsers using HTTP/1.1. Each account holds one persistent
* SSE connection for JMAP push; HTTP/1.1 caps origins at 6 concurrent
* connections, so 5 accounts leave one connection free for normal traffic.
* On HTTP/2+ this cap doesn't apply because streams are multiplexed.
*/
export const MAX_ACCOUNTS_HTTP1 = 5;
/**
* Detect whether the page has observed any HTTP/2 or HTTP/3 traffic.
*
* We walk recent resource-timing entries and treat a single h2/h3 sighting
* as a positive signal. Cross-origin entries may report an empty
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
* under-detect and fall back to the conservative cap — that's safe.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
for (let i = entries.length - 1; i >= 0; i--) {
const proto = entries[i].nextHopProtocol;
if (proto === 'h2' || proto === 'h3') return true;
}
return false;
}
/**
* Effective per-browser account cap. Lifts to {@link MAX_ACCOUNT_SLOTS}
* once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}.
*/
export function getMaxAccounts(): number {
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export const SESSION_COOKIE = 'jmap_session';
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
/** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function sessionCookieName(slot: number): string {
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
}
+1 -1
View File
@@ -4,7 +4,7 @@ export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAU
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
/** Get the cookie name for a given account slot. Slot 0 uses the legacy name. */
export function refreshTokenCookieName(slot: number): string {
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
}
+5 -2
View File
@@ -2,6 +2,7 @@ import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
export interface StalwartCredentials {
/** URL of the JMAP server (used for JMAP + management method calls) */
@@ -15,14 +16,16 @@ export interface StalwartCredentials {
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
const slot = parseInt(raw, 10);
return Number.isNaN(slot) || slot < 0 || slot > 4 ? null : slot;
return Number.isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS ? null : slot;
}
const ALL_SLOTS = Array.from({ length: MAX_ACCOUNT_SLOTS }, (_, i) => i);
function getCandidateSlots(request: NextRequest): number[] {
const requestedSlot = parseSlot(request.headers.get('X-JMAP-Cookie-Slot'))
?? parseSlot(request.nextUrl.searchParams.get('slot'));
return requestedSlot === null ? [0, 1, 2, 3, 4] : [requestedSlot];
return requestedSlot === null ? ALL_SLOTS : [requestedSlot];
}
export async function getStalwartCredentials(request: NextRequest): Promise<StalwartCredentials | null> {