import type { IncomingMessage, ServerResponse } from 'node:http'; import type { Brand } from '../im/lark/lark-hosts.js'; import { type ControlAuditSink } from './control-audit.js'; export declare const DASHBOARD_SESSION_COOKIE = "botmux_dashboard_session"; export declare const DEFAULT_DASHBOARD_SESSION_TTL_MS: number; export declare const DASHBOARD_H5_CLIENT_TIMEOUT_MS = 8000; /** Upper bound for the configured reverse-proxy hop count. Deployments chain a * handful of proxies at most; a typo of `99` must not turn into "trust the * whole client-supplied chain". */ export declare const DASHBOARD_H5_MAX_TRUSTED_PROXY_HOPS = 8; export interface DashboardH5AuthConfig { enabled: boolean; brand: Brand; appId: string; appSecret: string; allowedOpenIds: readonly string[]; entryPath: string; sessionTtlMs: number; secureCookies: boolean; /** * How many reverse-proxy hops sit in front of this dashboard and may be * trusted to have written `x-forwarded-for`. `0` (the default, and the only * safe value for a directly-exposed dashboard) means the header is ignored * outright and the socket peer address is the client. Required rather than * optional so every construction site states its proxy posture. */ trustedProxyHops: number; } export interface DashboardAuthIdentity { kind: 'feishu-h5'; userId: string; authSessionId: string; issuedAt: number; expiresAt: number; } export interface FeishuH5CodeExchanger { exchange(code: string, signal?: AbortSignal): Promise<{ openId: string; }>; } export type DashboardSessionEndReason = 'expired' | 'logout'; export interface DashboardSessionStoreOptions { ttlMs?: number; now?: () => number; randomToken?: () => string; setTimer?: typeof setTimeout; clearTimer?: typeof clearTimeout; } /** * In-memory fixed-expiry Dashboard sessions. Only SHA-256 token digests are * retained; the opaque browser cookie value exists only during Set-Cookie and * request parsing. A dashboard restart intentionally signs everyone out. */ export declare class DashboardSessionStore { private readonly sessionsByHash; private readonly hashesById; private readonly listeners; private readonly ttlMs; private readonly now; private readonly randomToken; private readonly schedule; private readonly cancel; constructor(opts?: DashboardSessionStoreOptions); create(userId: string): { token: string; identity: DashboardAuthIdentity; }; resolveToken(token: string | undefined): DashboardAuthIdentity | null; revokeToken(token: string | undefined): boolean; revokeAuthSession(authSessionId: string, reason?: DashboardSessionEndReason): boolean; /** Whether this auth session is still alive (present and unexpired). Used as * the revocation check for capabilities minted under the session (P1-5); a * due-but-unswept session is expired inline so the answer is authoritative. */ liveAuthSession(authSessionId: string): boolean; sweepExpired(): number; onEnd(listener: (identity: DashboardAuthIdentity, reason: DashboardSessionEndReason) => void): () => void; private expireHash; private endHash; private publicIdentity; } export declare function validOpenId(value: string): boolean; export declare function parseNamedCookie(header: string | undefined, name: string): string | undefined; export declare function dashboardSessionCookie(token: string, ttlMs: number, secure?: boolean): string; export declare function clearDashboardSessionCookie(secure?: boolean): string; export declare function resolveDashboardH5AuthConfig(env?: NodeJS.ProcessEnv): DashboardH5AuthConfig; export interface FeishuH5CodeExchangerOptions { fetchImpl?: typeof fetch; timeoutMs?: number; } /** * Feishu H5 `requestAccess` / `requestAuthCode` compatible exchange. Tokens * from Feishu stay in local variables long enough to fetch `open_id` and are * never cached, logged, returned, or copied into the Dashboard session. */ export declare function createFeishuH5CodeExchanger(config: Pick, opts?: FeishuH5CodeExchangerOptions): FeishuH5CodeExchanger; export declare const DASHBOARD_H5_EXCHANGE_WINDOW_MS = 60000; export declare const DASHBOARD_H5_EXCHANGE_MAX_PER_IP_PER_WINDOW = 10; export declare const DASHBOARD_H5_EXCHANGE_MAX_CONCURRENT = 4; /** Ceiling for the endpoint as a whole, across every source address. The * per-IP window alone bounds one client; this bounds the endpoint when the * attacker has many source addresses (or one trusted proxy in front of many * forged client addresses). */ export declare const DASHBOARD_H5_EXCHANGE_MAX_GLOBAL_PER_WINDOW = 60; /** Hard cap on tracked per-IP buckets. Reaching it evicts the least recently * used bucket (O(1)); it never degrades into a per-request full-table scan. */ export declare const DASHBOARD_H5_EXCHANGE_MAX_TRACKED_IPS = 4096; /** * Rate-limit bucket key for the public exchange endpoint, resolved under a * TRUSTED-PROXY discipline (`proxy-addr` semantics, zero dependencies). * * `x-forwarded-for` is client-supplied text: anyone can send it, and each * proxy only appends. Trusting the leftmost hop therefore hands an attacker an * unlimited supply of distinct bucket keys — a per-IP limiter you can opt out * of by typing a new IP, plus an attacker-driven tracking table. * * So the chain is read from the end WE control: `[socket peer, ...forwarded * reversed]`, and `trustedProxyHops` says how many of those leading entries * were written by infrastructure we trust. The first entry past them is the * client. With the default `0` the header is never consulted at all; with `1` * only the rightmost `x-forwarded-for` entry counts — the one the single * trusted proxy wrote itself — so any value the client prepended sits beyond * the trusted window and can never become a bucket key. A shorter-than- * configured chain falls back to the furthest address actually present, which * is at worst the direct peer. * * The value is only ever an opaque bucket key: it grants nothing. */ export declare function dashboardH5ClientIp(req: IncomingMessage, trustedProxyHops?: number): string; export interface DashboardH5ExchangeGateOptions { now?: () => number; windowMs?: number; maxPerIpPerWindow?: number; maxGlobalPerWindow?: number; maxConcurrent?: number; maxTrackedIps?: number; maxSpentCodes?: number; spentCodeTtlMs?: number; pruneIntervalMs?: number; } export type H5ExchangeAdmission = { ok: true; } | { ok: false; retryAfterMs: number; }; /** * In-process brakes for the public, unauthenticated H5 exchange endpoint. * One exchange can cost up to three open-platform requests, so several * independent limits apply (LocateRateLimiter-style, zero dependencies). * * The admission half (`admit`/`prune`) is deliberately generic and is reused by * the other pre-auth public surface, `GET /workbench-ticket/` (see * dashboard/workbench-ticket.ts), with its own budgets; only the code-specific * halves (`share`/`markSpent`) are exchange-only: * * - per-IP sliding window (`admit`) — only admitted hits consume slots, so * a refused burst cannot lock a NAT'd office out forever; * - global sliding window (`admit`) — the same endpoint-wide ceiling in * requests-per-window, so "many source addresses" is not a way around the * per-IP budget. It is checked AFTER the per-IP budget and consumed only on * admission, so one noisy client can spend at most its own per-IP quota out * of the shared pool. Implemented as a fixed-size ring of admission * timestamps: exact sliding-window semantics with an honest Retry-After, in * O(1) per request and O(cap) memory; * - bounded IP tracking — the table never exceeds `maxTrackedIps`; a new * bucket past the cap evicts the least recently used one in O(1). Pruning * idle buckets (`prune`) is a memory tidy-up on a timer, NOT the bound, so * a saturated table can never turn into a full-table scan per request; * - single-flight per code plus a global in-flight cap (`share`) — * concurrent duplicates of one code join the same upstream flight, and * distinct codes beyond the cap are fast-rejected rather than queued so * spam cannot stack pending exchanges on the dashboard event loop; * - spent-code memory (`markSpent`/`isSpent`) — a code that already minted a * session is refused outright afterwards, so "one-time" holds for a * sequential replay too, not just for the concurrent window. Only the code * DIGEST is kept (never the code, never the session token), with a TTL and * its own bounded LRU. * * The clock is injectable for tests. */ export declare class DashboardH5ExchangeGate { /** Insertion-ordered ⇒ iteration starts at the least recently used bucket. */ private readonly hitsByIp; private readonly inFlightByKey; /** code digest → expiry. Uniform TTL ⇒ insertion order is also expiry order. */ private readonly spentByKey; private readonly globalHits; private globalNext; private globalFilled; private readonly now; private readonly windowMs; private readonly maxPerIpPerWindow; private readonly maxTrackedIps; private readonly maxSpentCodes; private readonly spentCodeTtlMs; private readonly maxConcurrent; private readonly pruneIntervalMs; private lastPruneAt; private sweeps; constructor(opts?: DashboardH5ExchangeGateOptions); /** * Per-IP then endpoint-wide sliding-window admission. Refusals carry an * honest Retry-After and consume nothing — neither budget, and (for an * unknown IP) not a tracking-table slot either. */ admit(ip: string): H5ExchangeAdmission; /** `null` when the endpoint-wide window still has room. */ private globalRetryAfterMs; /** Store a bucket at the young end of the LRU, evicting the oldest if the * table is full. Constant time: `delete` + `set` re-inserts, and at most one * entry is dropped per new key. */ private remember; /** Remember that this code digest already minted a session. */ markSpent(key: string): void; /** Whether this code digest already minted a session (within the TTL). */ isSpent(key: string): boolean; /** * Single-flight + global concurrency. A key already in flight joins the * existing promise (never re-hits the open platform, exempt from the cap); * a new key beyond `maxConcurrent` is refused for a fast 429. */ share(key: string, start: () => Promise): { ok: true; result: Promise; } | { ok: false; retryAfterMs: number; }; /** Drop IP buckets and spent-code digests that have left their window. The * one full-table pass in this class — on a timer, never per request. */ prune(): void; trackedIpCount(): number; inFlightCount(): number; spentCodeCount(): number; /** Full-table sweeps performed so far. Diagnostic: this must stay flat as * request volume grows, otherwise admission has become O(table). */ sweepCount(): number; } export interface DashboardH5AuthControllerOptions { config: DashboardH5AuthConfig; sessions: DashboardSessionStore; exchanger?: FeishuH5CodeExchanger; exchangeGate?: DashboardH5ExchangeGate; audit: ControlAuditSink; } export interface DashboardH5AuthController { entryPath: string; exchangePath: string; sessionPath: string; logoutPath: string; resolve(req: IncomingMessage): DashboardAuthIdentity | null; handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise; } export declare function safeDashboardH5ReturnTo(value: string | null | undefined): string; export declare function createDashboardH5AuthController(opts: DashboardH5AuthControllerOptions): DashboardH5AuthController; //# sourceMappingURL=h5-auth.d.ts.map