{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/server/auth.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Dashboard auth — exactly two modes (this file is the authority):\n *\n * Mode A — local-only (default): the server binds loopback only. Requests are\n * additionally checked for loopback source address AND an allowlisted Host\n * header (DNS-rebinding defense: a malicious website can point its own domain\n * at 127.0.0.1 and drive the API from the victim's browser unless Host is\n * validated). No login, no pairing.\n *\n * Mode B — remote (explicit opt-in): requires Tailscale. Enforcement layers,\n * all fail-closed: (1) Tailscale identity resolution of the peer address,\n * (2) identity allowlist (empty allowlist = deny all), (3) first-login\n * rotating pairing code (visible only from the host/local dashboard),\n * (4) signed per-device cookie thereafter.\n *\n * There is no LAN mode. Any auth-subsystem error denies the request.\n */\n\nimport { execFile } from \"node:child_process\";\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\nconst PAIRING_CODE_STEP_MS = 30_000;\nconst DAY_MS = 24 * 60 * 60 * 1000;\nexport const DEFAULT_PAIRING_TTL_DAYS = 180;\nexport const MIN_PAIRING_TTL_DAYS = 1;\nexport const MAX_PAIRING_TTL_DAYS = 3650;\nconst DEFAULT_PAIRING_MAX_ATTEMPTS = 5;\nconst DEFAULT_PAIRING_LOCKOUT_MS = 60_000;\n\n// ---------------------------------------------------------------------------\n// Address / Host helpers\n// ---------------------------------------------------------------------------\n\n/** Normalize an address for comparison (strip IPv6-mapped IPv4 prefix and zone). */\nexport function normalizeAddress(address: string | undefined): string {\n\tif (!address) return \"\";\n\tlet a = address.trim();\n\tif (a.startsWith(\"[\") && a.endsWith(\"]\")) a = a.slice(1, -1);\n\tconst zone = a.indexOf(\"%\");\n\tif (zone !== -1) a = a.slice(0, zone);\n\tif (a.startsWith(\"::ffff:\")) a = a.slice(7);\n\treturn a;\n}\n\n/** True when the (normalized) address is a loopback address. */\nexport function isLoopbackAddress(address: string | undefined): boolean {\n\tconst a = normalizeAddress(address);\n\tif (!a) return false;\n\tif (a === \"::1\") return true;\n\treturn /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(a);\n}\n\n/**\n * Validate a Host header against the loopback allowlist. Rejecting foreign\n * hosts breaks DNS rebinding: the attacker's page can reach 127.0.0.1, but its\n * requests carry the attacker's hostname in Host.\n */\nexport function isAllowedLocalHost(hostHeader: string | undefined): boolean {\n\tif (!hostHeader) return false;\n\t// Strip port. IPv6 hosts arrive as \"[::1]:port\".\n\tlet host = hostHeader.trim().toLowerCase();\n\tconst v6 = host.match(/^\\[([^\\]]+)\\](?::\\d+)?$/);\n\tif (v6) {\n\t\thost = v6[1];\n\t} else {\n\t\tconst colon = host.lastIndexOf(\":\");\n\t\tif (colon !== -1 && /^\\d+$/.test(host.slice(colon + 1))) host = host.slice(0, colon);\n\t}\n\treturn host === \"localhost\" || host === \"::1\" || /^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(host);\n}\n\n// ---------------------------------------------------------------------------\n// Tailscale identity\n// ---------------------------------------------------------------------------\n\nexport interface TailscaleIdentity {\n\t/** Login name (e.g. \"alice@example.com\") — the allowlist unit. */\n\tloginName: string;\n\t/** Device host name, when known. */\n\tdevice?: string;\n}\n\nexport interface TailscaleResolver {\n\t/** Resolve a peer IP to a Tailscale identity, or null when unknown. */\n\tresolve(address: string): Promise<TailscaleIdentity | null>;\n}\n\ninterface TailscaleWhoisJson {\n\tNode?: { Name?: string };\n\tUserProfile?: { LoginName?: string } | null;\n}\n\nexport type TailscaleWhoisRunner = (address: string) => Promise<{ stdout: string }>;\n\nexport class TailscaleResolverError extends Error {\n\tconstructor(readonly kind: \"timeout\" | \"execution\" | \"parse\" | \"schema\") {\n\t\tsuper(`Tailscale identity resolver ${kind} failure`);\n\t\tthis.name = \"TailscaleResolverError\";\n\t}\n}\n\nasync function runTailscaleWhois(address: string): Promise<{ stdout: string }> {\n\treturn execFileAsync(\"tailscale\", [\"whois\", \"--json\", address], {\n\t\ttimeout: 3000,\n\t\tmaxBuffer: 1024 * 1024,\n\t});\n}\n\nfunction isPeerNotFoundError(error: unknown): boolean {\n\tconst stderr = (error as { stderr?: unknown })?.stderr;\n\treturn typeof stderr === \"string\" && stderr.trim().toLowerCase() === \"peer not found\";\n}\n\nfunction isTimeoutError(error: unknown): boolean {\n\tconst candidate = error as { killed?: unknown; signal?: unknown };\n\treturn candidate?.killed === true || candidate?.signal === \"SIGTERM\";\n}\n\n/** Peer-specific Tailscale identity resolution with same-peer in-flight coalescing. */\nexport class TailscaleWhoisResolver implements TailscaleResolver {\n\tprivate readonly inFlight = new Map<string, Promise<TailscaleIdentity | null>>();\n\n\tconstructor(private readonly runWhois: TailscaleWhoisRunner = runTailscaleWhois) {}\n\n\tasync resolve(address: string): Promise<TailscaleIdentity | null> {\n\t\tconst target = normalizeAddress(address);\n\t\tif (!target) return null;\n\t\tconst existing = this.inFlight.get(target);\n\t\tif (existing) return existing;\n\n\t\tconst lookup = this.lookup(target);\n\t\tthis.inFlight.set(target, lookup);\n\t\ttry {\n\t\t\treturn await lookup;\n\t\t} finally {\n\t\t\tif (this.inFlight.get(target) === lookup) this.inFlight.delete(target);\n\t\t}\n\t}\n\n\tprivate async lookup(target: string): Promise<TailscaleIdentity | null> {\n\t\tlet stdout: string;\n\t\ttry {\n\t\t\t({ stdout } = await this.runWhois(target));\n\t\t} catch (error) {\n\t\t\tif (isTimeoutError(error)) throw new TailscaleResolverError(\"timeout\");\n\t\t\tif (isPeerNotFoundError(error)) return null;\n\t\t\tthrow new TailscaleResolverError(\"execution\");\n\t\t}\n\n\t\tlet whois: TailscaleWhoisJson;\n\t\ttry {\n\t\t\tconst parsed: unknown = JSON.parse(stdout);\n\t\t\tif (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n\t\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t\t}\n\t\t\twhois = parsed as TailscaleWhoisJson;\n\t\t} catch (error) {\n\t\t\tif (error instanceof TailscaleResolverError) throw error;\n\t\t\tthrow new TailscaleResolverError(\"parse\");\n\t\t}\n\n\t\tif (!whois.Node || typeof whois.Node !== \"object\" || Array.isArray(whois.Node)) {\n\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t}\n\t\tif (whois.Node.Name !== undefined && typeof whois.Node.Name !== \"string\") {\n\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t}\n\t\tif (whois.UserProfile !== undefined && whois.UserProfile !== null) {\n\t\t\tif (typeof whois.UserProfile !== \"object\" || Array.isArray(whois.UserProfile)) {\n\t\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t\t}\n\t\t\tif (whois.UserProfile.LoginName !== undefined && typeof whois.UserProfile.LoginName !== \"string\") {\n\t\t\t\tthrow new TailscaleResolverError(\"schema\");\n\t\t\t}\n\t\t}\n\n\t\tconst loginName = whois.UserProfile?.LoginName?.trim();\n\t\tif (!loginName) return null;\n\t\tconst device = whois.Node.Name?.replace(/\\.$/, \"\") || undefined;\n\t\treturn { loginName, device };\n\t}\n}\n\n/** @deprecated Use TailscaleWhoisResolver. Retained for API compatibility. */\nexport class TailscaleStatusResolver extends TailscaleWhoisResolver {}\n\n// ---------------------------------------------------------------------------\n// Pairing store (rotating pairing codes + device tokens)\n// ---------------------------------------------------------------------------\n\nexport interface PairedDevice {\n\tid: string;\n\tidentity: string;\n\tdevice?: string;\n\tcreatedAt: string;\n\texpiresAt: string;\n}\n\nexport interface StoredPairing extends PairedDevice {\n\t/** HMAC of the device token (raw token never stored). */\n\ttokenHmac: string;\n\t/** UTC date of the last expiry warning claimed by this pairing. */\n\tlastExpiryWarningUtcDate?: string;\n}\n\nexport interface PairingState {\n\tpairings: StoredPairing[];\n\tconsumedPairingWindows: number[];\n\t/** Whole-day lifetime for newly created pairings. Absent in legacy files. */\n\tpairingTtlDays?: number;\n}\n\nexport interface PairingExpiryStatus {\n\twarning?: { expiresAt: string };\n\tnextCheckAt?: string;\n}\n\nexport interface PairingStorage {\n\tload(): Promise<PairingState>;\n\tsave(state: PairingState): Promise<void>;\n}\n\n/** In-memory storage — used in tests and as the base for the file store. */\nexport class MemoryPairingStorage implements PairingStorage {\n\tprivate state: PairingState = { pairings: [], consumedPairingWindows: [] };\n\tasync load(): Promise<PairingState> {\n\t\treturn {\n\t\t\tpairings: this.state.pairings.map((pairing) => ({ ...pairing })),\n\t\t\tconsumedPairingWindows: [...this.state.consumedPairingWindows],\n\t\t\tpairingTtlDays: this.state.pairingTtlDays,\n\t\t};\n\t}\n\tasync save(state: PairingState): Promise<void> {\n\t\tthis.state = {\n\t\t\tpairings: state.pairings.map((pairing) => ({ ...pairing })),\n\t\t\tconsumedPairingWindows: [...state.consumedPairingWindows],\n\t\t\tpairingTtlDays: state.pairingTtlDays,\n\t\t};\n\t}\n}\n\nexport interface DashboardAuthOptions {\n\t/** Remote (Tailscale) mode. Default false — loopback only. */\n\tremoteEnabled?: boolean;\n\t/** Allowed Tailscale login names. Empty = deny all remote. */\n\tallowedIdentities?: string[];\n\t/** Test/backward-compatible default when no persisted day setting exists. Production defaults to 180 days. */\n\tpairingTtlMs?: number;\n\tresolver?: TailscaleResolver;\n\tstorage?: PairingStorage;\n\t/** HMAC/TOTP secret for device tokens and pairing codes. Production passes a per-install persisted secret. */\n\tsecret?: Buffer;\n\t/** Failed PIN attempts before temporary lockout. Default 5. */\n\tpairingMaxAttempts?: number;\n\t/** Temporary lockout duration after too many failed PIN attempts. Default 60s. */\n\tpairingLockoutMs?: number;\n\t/** Security/audit log sink for repeated failed pairing attempts. */\n\tlogger?: (line: string) => void;\n\t/** Clock override for tests. */\n\tnow?: () => number;\n}\n\nexport type AuthDecision =\n\t| { allowed: true; mode: \"local\" }\n\t| { allowed: true; mode: \"remote\"; identity: TailscaleIdentity; pairing: PairedDevice }\n\t| {\n\t\t\tallowed: false;\n\t\t\tstatus: number;\n\t\t\treason: string;\n\t\t\t/** Set when an allowed identity needs pairing-code entry. */\n\t\t\tneedsPairing?: boolean;\n\t\t\tidentity?: TailscaleIdentity;\n\t  };\n\nexport interface AuthRequestInfo {\n\tremoteAddress: string | undefined;\n\thostHeader: string | undefined;\n\t/** Origin header when present. Non-loopback origins are rejected on local requests. */\n\toriginHeader: string | undefined;\n\t/** Value of the dashboard device cookie, when present. */\n\tdeviceToken: string | undefined;\n}\n\nfunction timingSafeEqualStr(a: string, b: string): boolean {\n\tconst ab = Buffer.from(a);\n\tconst bb = Buffer.from(b);\n\tif (ab.length !== bb.length) return false;\n\treturn timingSafeEqual(ab, bb);\n}\n\nexport class DashboardAuth {\n\tprivate readonly remoteEnabled: boolean;\n\tprivate readonly allowedIdentities: Set<string>;\n\tprivate readonly defaultPairingTtlMs: number;\n\tprivate readonly resolver: TailscaleResolver;\n\tprivate readonly storage: PairingStorage;\n\tprivate readonly secret: Buffer;\n\tprivate readonly pairingMaxAttempts: number;\n\tprivate readonly pairingLockoutMs: number;\n\tprivate readonly logger: (line: string) => void;\n\tprivate readonly now: () => number;\n\tprivate pairingMutation: Promise<void> = Promise.resolve();\n\tprivate readonly pairingFailures = new Map<string, { count: number; lockedUntil?: number }>();\n\n\tconstructor(options: DashboardAuthOptions = {}) {\n\t\tthis.remoteEnabled = options.remoteEnabled ?? false;\n\t\tthis.allowedIdentities = new Set(options.allowedIdentities ?? []);\n\t\tthis.defaultPairingTtlMs = options.pairingTtlMs ?? DEFAULT_PAIRING_TTL_DAYS * DAY_MS;\n\t\tthis.resolver = options.resolver ?? new TailscaleWhoisResolver();\n\t\tthis.storage = options.storage ?? new MemoryPairingStorage();\n\t\tthis.secret = options.secret ?? randomBytes(32);\n\t\tthis.pairingMaxAttempts = options.pairingMaxAttempts ?? DEFAULT_PAIRING_MAX_ATTEMPTS;\n\t\tthis.pairingLockoutMs = options.pairingLockoutMs ?? DEFAULT_PAIRING_LOCKOUT_MS;\n\t\tthis.logger = options.logger ?? (() => {});\n\t\tthis.now = options.now ?? Date.now;\n\t}\n\n\tget isRemoteEnabled(): boolean {\n\t\treturn this.remoteEnabled;\n\t}\n\n\tprivate defaultPairingTtlDays(): number {\n\t\tconst days = this.defaultPairingTtlMs / DAY_MS;\n\t\treturn Number.isSafeInteger(days) && days >= MIN_PAIRING_TTL_DAYS && days <= MAX_PAIRING_TTL_DAYS\n\t\t\t? days\n\t\t\t: DEFAULT_PAIRING_TTL_DAYS;\n\t}\n\n\tasync getPairingSettings(): Promise<{ pairingTtlDays: number }> {\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\treturn { pairingTtlDays: state.pairingTtlDays ?? this.defaultPairingTtlDays() };\n\t\t});\n\t}\n\n\tasync setPairingSettings(pairingTtlDays: number): Promise<{ pairingTtlDays: number }> {\n\t\tif (\n\t\t\t!Number.isSafeInteger(pairingTtlDays) ||\n\t\t\tpairingTtlDays < MIN_PAIRING_TTL_DAYS ||\n\t\t\tpairingTtlDays > MAX_PAIRING_TTL_DAYS\n\t\t) {\n\t\t\tthrow Object.assign(\n\t\t\t\tnew Error(\n\t\t\t\t\t`pairingTtlDays must be a whole number from ${MIN_PAIRING_TTL_DAYS} through ${MAX_PAIRING_TTL_DAYS}`,\n\t\t\t\t),\n\t\t\t\t{ status: 400 },\n\t\t\t);\n\t\t}\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tawait this.storage.save({ ...state, pairingTtlDays });\n\t\t\treturn { pairingTtlDays };\n\t\t});\n\t}\n\n\tprivate hmac(value: string): string {\n\t\treturn createHmac(\"sha256\", this.secret).update(value).digest(\"hex\");\n\t}\n\n\tprivate pairingCodeForWindow(window: number): string {\n\t\tconst counter = Buffer.alloc(8);\n\t\tcounter.writeBigUInt64BE(BigInt(window));\n\t\tconst digest = createHmac(\"sha1\", this.secret).update(counter).digest();\n\t\tconst offset = digest[digest.length - 1]! & 0x0f;\n\t\tconst value =\n\t\t\t((digest[offset]! & 0x7f) << 24) |\n\t\t\t((digest[offset + 1]! & 0xff) << 16) |\n\t\t\t((digest[offset + 2]! & 0xff) << 8) |\n\t\t\t(digest[offset + 3]! & 0xff);\n\t\treturn String(value % 1_000_000).padStart(6, \"0\");\n\t}\n\n\t/** Current RFC-6238-style rotating code for pairing new remote devices. */\n\tcurrentPairingCode(): { code: string; expiresInMs: number } {\n\t\tconst nowMs = this.now();\n\t\tconst window = Math.floor(nowMs / PAIRING_CODE_STEP_MS);\n\t\tconst expiresInMs = (window + 1) * PAIRING_CODE_STEP_MS - nowMs;\n\t\treturn { code: this.pairingCodeForWindow(window), expiresInMs };\n\t}\n\n\tprivate currentPairingWindow(): number {\n\t\treturn Math.floor(this.now() / PAIRING_CODE_STEP_MS);\n\t}\n\n\tprivate matchingPairingCodeWindow(code: string): number | undefined {\n\t\tif (!/^\\d{6}$/.test(code)) return undefined;\n\t\tconst window = this.currentPairingWindow();\n\t\tfor (const candidateWindow of [window - 1, window, window + 1]) {\n\t\t\tif (candidateWindow < 0) continue;\n\t\t\tif (timingSafeEqualStr(code, this.pairingCodeForWindow(candidateWindow))) return candidateWindow;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate pruneConsumedPairingWindows(windows: Iterable<number>): number[] {\n\t\tconst minimumAcceptedWindow = Math.max(0, this.currentPairingWindow() - 1);\n\t\treturn [\n\t\t\t...new Set([...windows].filter((window) => Number.isSafeInteger(window) && window >= minimumAcceptedWindow)),\n\t\t].sort((a, b) => a - b);\n\t}\n\n\tprivate samePairingWindows(a: number[], b: number[]): boolean {\n\t\treturn a.length === b.length && a.every((window, index) => window === b[index]);\n\t}\n\n\tprivate pairingFailureKey(identity: TailscaleIdentity, remoteAddress: string | undefined): string {\n\t\treturn `${identity.loginName}|${normalizeAddress(remoteAddress)}`;\n\t}\n\n\tprivate assertPairingNotLocked(key: string): void {\n\t\tconst failure = this.pairingFailures.get(key);\n\t\tif (!failure?.lockedUntil) return;\n\t\tif (failure.lockedUntil > this.now()) {\n\t\t\tthrow Object.assign(new Error(\"Too many incorrect pairing attempts; try again later\"), { status: 429 });\n\t\t}\n\t\tthis.pairingFailures.delete(key);\n\t}\n\n\tprivate recordPairingFailure(key: string, identity: TailscaleIdentity): never {\n\t\tconst current = this.pairingFailures.get(key);\n\t\tconst count = (current?.count ?? 0) + 1;\n\t\tif (count >= this.pairingMaxAttempts) {\n\t\t\tconst lockedUntil = this.now() + this.pairingLockoutMs;\n\t\t\tthis.pairingFailures.set(key, { count, lockedUntil });\n\t\t\tthis.logger(\n\t\t\t\t`pairing locked after ${count} failed attempts for ${identity.loginName} until ${new Date(lockedUntil).toISOString()}`,\n\t\t\t);\n\t\t\tthrow Object.assign(new Error(\"Too many incorrect pairing attempts; try again later\"), { status: 429 });\n\t\t}\n\t\tthis.pairingFailures.set(key, { count, lockedUntil: current?.lockedUntil });\n\t\tif (count > 1) this.logger(`pairing failed attempt ${count} for ${identity.loginName}`);\n\t\tthrow Object.assign(new Error(\"Incorrect pairing code\"), { status: 401 });\n\t}\n\n\tprivate clearPairingFailures(key: string): void {\n\t\tthis.pairingFailures.delete(key);\n\t}\n\n\tprivate async withPairingMutation<T>(fn: () => Promise<T>): Promise<T> {\n\t\tconst previous = this.pairingMutation;\n\t\tlet release!: () => void;\n\t\tthis.pairingMutation = new Promise<void>((resolve) => {\n\t\t\trelease = resolve;\n\t\t});\n\t\tawait previous;\n\t\ttry {\n\t\t\treturn await fn();\n\t\t} finally {\n\t\t\trelease();\n\t\t}\n\t}\n\n\t/**\n\t * Authenticate a request. Fail-closed: any resolver/storage error results in\n\t * a deny, never a pass-through.\n\t */\n\tasync authenticate(info: AuthRequestInfo): Promise<AuthDecision> {\n\t\ttry {\n\t\t\treturn await this.authenticateInner(info);\n\t\t} catch (err) {\n\t\t\tif (err instanceof TailscaleResolverError) {\n\t\t\t\tthis.logger(`identity resolver ${err.kind} failure — denying`);\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 500,\n\t\t\t\treason: `Auth subsystem error — denying: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t};\n\t\t}\n\t}\n\n\tprivate async authenticateInner(info: AuthRequestInfo): Promise<AuthDecision> {\n\t\tif (isLoopbackAddress(info.remoteAddress)) {\n\t\t\tif (!isAllowedLocalHost(info.hostHeader)) {\n\t\t\t\treturn {\n\t\t\t\t\tallowed: false,\n\t\t\t\t\tstatus: 403,\n\t\t\t\t\treason: `Host header \"${info.hostHeader ?? \"(missing)\"}\" is not a loopback host — rejected (DNS-rebinding defense)`,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (info.originHeader) {\n\t\t\t\tlet originHost: string | undefined;\n\t\t\t\ttry {\n\t\t\t\t\toriginHost = new URL(info.originHeader).host;\n\t\t\t\t} catch {\n\t\t\t\t\toriginHost = undefined;\n\t\t\t\t}\n\t\t\t\tif (!originHost || !isAllowedLocalHost(originHost)) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tallowed: false,\n\t\t\t\t\t\tstatus: 403,\n\t\t\t\t\t\treason: `Origin \"${info.originHeader}\" is not a loopback origin — rejected (cross-site defense)`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn { allowed: true, mode: \"local\" };\n\t\t}\n\n\t\tif (!this.remoteEnabled) {\n\t\t\treturn { allowed: false, status: 403, reason: \"Remote dashboard access is disabled\" };\n\t\t}\n\n\t\tconst identity = await this.resolver.resolve(info.remoteAddress ?? \"\");\n\t\tif (!identity) {\n\t\t\treturn { allowed: false, status: 403, reason: \"Client is not a known Tailscale peer\" };\n\t\t}\n\t\tif (this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 403,\n\t\t\t\treason: `Tailscale identity \"${identity.loginName}\" is not on the dashboard allowlist`,\n\t\t\t\tidentity,\n\t\t\t};\n\t\t}\n\n\t\tconst pairing = info.deviceToken ? await this.findPairing(identity, info.deviceToken) : undefined;\n\t\tif (!pairing) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tstatus: 401,\n\t\t\t\treason: \"Device is not paired — pairing code required\",\n\t\t\t\tneedsPairing: true,\n\t\t\t\tidentity,\n\t\t\t};\n\t\t}\n\n\t\treturn { allowed: true, mode: \"remote\", identity, pairing: this.toPairedDevice(pairing) };\n\t}\n\n\t/**\n\t * Complete pairing for an allowed remote identity using the current rotating\n\t * code. Returns the device token to set as a cookie. Throws (with `status`) on\n\t * any failure.\n\t */\n\tasync pair(info: AuthRequestInfo, code: string): Promise<{ token: string; device: PairedDevice }> {\n\t\tif (isLoopbackAddress(info.remoteAddress)) {\n\t\t\tthrow Object.assign(new Error(\"Loopback clients do not pair\"), { status: 400 });\n\t\t}\n\t\tif (!this.remoteEnabled) {\n\t\t\tthrow Object.assign(new Error(\"Remote dashboard access is disabled\"), { status: 403 });\n\t\t}\n\t\tconst identity = await this.resolver.resolve(info.remoteAddress ?? \"\");\n\t\tif (!identity || this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {\n\t\t\tthrow Object.assign(new Error(\"Identity is not on the dashboard allowlist\"), { status: 403 });\n\t\t}\n\t\tconst failureKey = this.pairingFailureKey(identity, info.remoteAddress);\n\t\tthis.assertPairingNotLocked(failureKey);\n\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst matchedWindow = this.matchingPairingCodeWindow(code);\n\t\t\tif (matchedWindow === undefined || state.consumedPairingWindows.includes(matchedWindow)) {\n\t\t\t\tthis.recordPairingFailure(failureKey, identity);\n\t\t\t}\n\n\t\t\tconst token = randomBytes(32).toString(\"base64url\");\n\t\t\tconst nowMs = this.now();\n\t\t\tconst pairingTtlMs =\n\t\t\t\tstate.pairingTtlDays === undefined ? this.defaultPairingTtlMs : state.pairingTtlDays * DAY_MS;\n\t\t\tconst device: PairedDevice = {\n\t\t\t\tid: randomBytes(8).toString(\"hex\"),\n\t\t\t\tidentity: identity.loginName,\n\t\t\t\tdevice: identity.device,\n\t\t\t\tcreatedAt: new Date(nowMs).toISOString(),\n\t\t\t\texpiresAt: new Date(nowMs + pairingTtlMs).toISOString(),\n\t\t\t};\n\t\t\tconst pairings = [...state.pairings, { ...device, tokenHmac: this.hmac(token) }];\n\t\t\tconst consumedPairingWindows = this.pruneConsumedPairingWindows([\n\t\t\t\t...state.consumedPairingWindows,\n\t\t\t\tmatchedWindow,\n\t\t\t]);\n\t\t\tawait this.storage.save({ ...state, pairings, consumedPairingWindows });\n\t\t\tthis.clearPairingFailures(failureKey);\n\t\t\treturn { token, device };\n\t\t});\n\t}\n\n\tprivate toPairedDevice(pairing: StoredPairing): PairedDevice {\n\t\treturn {\n\t\t\tid: pairing.id,\n\t\t\tidentity: pairing.identity,\n\t\t\tdevice: pairing.device,\n\t\t\tcreatedAt: pairing.createdAt,\n\t\t\texpiresAt: pairing.expiresAt,\n\t\t};\n\t}\n\n\t/** List paired devices (live only). */\n\tasync listDevices(): Promise<PairedDevice[]> {\n\t\treturn this.withPairingMutation(async () =>\n\t\t\t(await this.loadLive()).map((pairing) => this.toPairedDevice(pairing)),\n\t\t);\n\t}\n\n\t/** Remove a paired device by id. Returns true when something was removed. */\n\tasync unpair(deviceId: string): Promise<boolean> {\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst remaining = state.pairings.filter((p) => p.id !== deviceId);\n\t\t\tif (remaining.length === state.pairings.length) return false;\n\t\t\tawait this.storage.save({ ...state, pairings: remaining });\n\t\t\treturn true;\n\t\t});\n\t}\n\n\tprivate async findPairing(identity: TailscaleIdentity, token: string): Promise<StoredPairing | undefined> {\n\t\tconst tokenHmac = this.hmac(token);\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst live = await this.loadLive();\n\t\t\treturn live.find((p) => p.identity === identity.loginName && timingSafeEqualStr(p.tokenHmac, tokenHmac));\n\t\t});\n\t}\n\n\t/** Atomically claim any due warning and return the next useful browser check time. */\n\tasync claimPairingExpiryStatus(pairingId: string): Promise<PairingExpiryStatus> {\n\t\treturn this.withPairingMutation(async () => {\n\t\t\tconst state = await this.loadLiveState();\n\t\t\tconst pairing = state.pairings.find((candidate) => candidate.id === pairingId);\n\t\t\tif (!pairing) return {};\n\n\t\t\tconst nowMs = this.now();\n\t\t\tconst createdAt = Date.parse(pairing.createdAt);\n\t\t\tconst expiresAt = Date.parse(pairing.expiresAt);\n\t\t\tconst originalValidity = expiresAt - createdAt;\n\t\t\tconst remaining = expiresAt - nowMs;\n\t\t\tif (!Number.isFinite(originalValidity) || originalValidity <= 0 || remaining <= 0) return {};\n\n\t\t\tconst warningStartsAt = expiresAt - originalValidity * 0.1;\n\t\t\tif (nowMs < warningStartsAt) return { nextCheckAt: new Date(warningStartsAt).toISOString() };\n\n\t\t\tconst utcDate = new Date(nowMs).toISOString().slice(0, 10);\n\t\t\tconst nextUtcMidnight = Date.parse(`${utcDate}T00:00:00.000Z`) + DAY_MS;\n\t\t\tconst nextCheckAt = nextUtcMidnight < expiresAt ? new Date(nextUtcMidnight).toISOString() : undefined;\n\t\t\tif (pairing.lastExpiryWarningUtcDate === utcDate) return { nextCheckAt };\n\n\t\t\tconst pairings = state.pairings.map((candidate) =>\n\t\t\t\tcandidate.id === pairingId ? { ...candidate, lastExpiryWarningUtcDate: utcDate } : candidate,\n\t\t\t);\n\t\t\tawait this.storage.save({ ...state, pairings });\n\t\t\treturn { warning: { expiresAt: pairing.expiresAt }, nextCheckAt };\n\t\t});\n\t}\n\n\t/** Load pairings, dropping (and persisting the removal of) expired entries. Caller must hold pairingMutation. */\n\tprivate async loadLive(saveExpiredRemoval = true): Promise<StoredPairing[]> {\n\t\treturn (await this.loadLiveState(saveExpiredRemoval)).pairings;\n\t}\n\n\t/** Load pairing state, dropping expired devices and consumed PIN windows that can no longer match. */\n\tprivate async loadLiveState(savePrunedState = true): Promise<PairingState> {\n\t\tconst state = await this.storage.load();\n\t\tconst nowMs = this.now();\n\t\tconst pairings = state.pairings.filter((p) => new Date(p.expiresAt).getTime() > nowMs);\n\t\tconst consumedPairingWindows = this.pruneConsumedPairingWindows(state.consumedPairingWindows);\n\t\tif (\n\t\t\tsavePrunedState &&\n\t\t\t(pairings.length !== state.pairings.length ||\n\t\t\t\t!this.samePairingWindows(consumedPairingWindows, state.consumedPairingWindows))\n\t\t) {\n\t\t\tawait this.storage.save({ ...state, pairings, consumedPairingWindows });\n\t\t}\n\t\treturn { ...state, pairings, consumedPairingWindows };\n\t}\n}\n"]}