@dreb/dashboard 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +146 -0
  2. package/dist/index.d.ts +30 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +251 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/server/auth.d.ts +164 -0
  7. package/dist/server/auth.d.ts.map +1 -0
  8. package/dist/server/auth.js +396 -0
  9. package/dist/server/auth.js.map +1 -0
  10. package/dist/server/event-hub.d.ts +31 -0
  11. package/dist/server/event-hub.d.ts.map +1 -0
  12. package/dist/server/event-hub.js +73 -0
  13. package/dist/server/event-hub.js.map +1 -0
  14. package/dist/server/files.d.ts +48 -0
  15. package/dist/server/files.d.ts.map +1 -0
  16. package/dist/server/files.js +194 -0
  17. package/dist/server/files.js.map +1 -0
  18. package/dist/server/pairing-storage.d.ts +18 -0
  19. package/dist/server/pairing-storage.d.ts.map +1 -0
  20. package/dist/server/pairing-storage.js +83 -0
  21. package/dist/server/pairing-storage.js.map +1 -0
  22. package/dist/server/runtime-pool.d.ts +89 -0
  23. package/dist/server/runtime-pool.d.ts.map +1 -0
  24. package/dist/server/runtime-pool.js +377 -0
  25. package/dist/server/runtime-pool.js.map +1 -0
  26. package/dist/server/server.d.ts +30 -0
  27. package/dist/server/server.d.ts.map +1 -0
  28. package/dist/server/server.js +557 -0
  29. package/dist/server/server.js.map +1 -0
  30. package/dist/server/subagent-log.d.ts +42 -0
  31. package/dist/server/subagent-log.d.ts.map +1 -0
  32. package/dist/server/subagent-log.js +155 -0
  33. package/dist/server/subagent-log.js.map +1 -0
  34. package/dist/shared/protocol.d.ts +261 -0
  35. package/dist/shared/protocol.d.ts.map +1 -0
  36. package/dist/shared/protocol.js +26 -0
  37. package/dist/shared/protocol.js.map +1 -0
  38. package/dist/static/assets/index-Cgwg2zRA.js +77 -0
  39. package/dist/static/assets/index-DnFrQoI9.css +1 -0
  40. package/dist/static/icons/apple-touch-icon.png +0 -0
  41. package/dist/static/icons/favicon-32.png +0 -0
  42. package/dist/static/icons/punk-1024.png +0 -0
  43. package/dist/static/icons/punk-192.png +0 -0
  44. package/dist/static/icons/punk-512.png +0 -0
  45. package/dist/static/index.html +32 -0
  46. package/dist/static/manifest.webmanifest +30 -0
  47. package/dist/static/sw.js +110 -0
  48. package/package.json +57 -0
@@ -0,0 +1 @@
1
+ {"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 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 TailscaleStatusPeer {\n\tTailscaleIPs?: string[];\n\tHostName?: string;\n\tUserID?: number;\n}\n\ninterface TailscaleStatusJson {\n\tSelf?: TailscaleStatusPeer & { UserID?: number };\n\tPeer?: Record<string, TailscaleStatusPeer>;\n\tUser?: Record<string, { LoginName?: string }>;\n}\n\n/** Resolves identities via `tailscale status --json`. Any failure resolves null (deny). */\nexport class TailscaleStatusResolver implements TailscaleResolver {\n\tasync resolve(address: string): Promise<TailscaleIdentity | null> {\n\t\tconst target = normalizeAddress(address);\n\t\tif (!target) return null;\n\t\tlet status: TailscaleStatusJson;\n\t\ttry {\n\t\t\tconst { stdout } = await execFileAsync(\"tailscale\", [\"status\", \"--json\"], {\n\t\t\t\ttimeout: 3000,\n\t\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\t});\n\t\t\tstatus = JSON.parse(stdout) as TailscaleStatusJson;\n\t\t} catch {\n\t\t\t// Tailscale absent, not running, or unparseable — fail closed.\n\t\t\treturn null;\n\t\t}\n\t\tconst peers = Object.values(status.Peer ?? {});\n\t\tif (status.Self) peers.push(status.Self);\n\t\tfor (const peer of peers) {\n\t\t\tif (!peer.TailscaleIPs?.some((ip) => normalizeAddress(ip) === target)) continue;\n\t\t\tconst userId = peer.UserID;\n\t\t\tconst loginName = userId !== undefined ? status.User?.[String(userId)]?.LoginName : undefined;\n\t\t\tif (!loginName) return null; // identity unknown — deny rather than guess\n\t\t\treturn { loginName, device: peer.HostName };\n\t\t}\n\t\treturn null;\n\t}\n}\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}\n\nexport interface PairingState {\n\tpairings: StoredPairing[];\n\tconsumedPairingWindows: number[];\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],\n\t\t\tconsumedPairingWindows: [...this.state.consumedPairingWindows],\n\t\t};\n\t}\n\tasync save(state: PairingState): Promise<void> {\n\t\tthis.state = {\n\t\t\tpairings: [...state.pairings],\n\t\t\tconsumedPairingWindows: [...state.consumedPairingWindows],\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/** Device pairing validity. Default 30 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 }\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 pairingTtlMs: 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.pairingTtlMs = options.pairingTtlMs ?? 30 * 24 * 60 * 60 * 1000;\n\t\tthis.resolver = options.resolver ?? new TailscaleStatusResolver();\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 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\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\tif (!info.deviceToken || !(await this.isPaired(identity, info.deviceToken))) {\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 };\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 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 + this.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({ pairings, consumedPairingWindows });\n\t\t\tthis.clearPairingFailures(failureKey);\n\t\t\treturn { token, device };\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(({ tokenHmac: _tokenHmac, ...device }) => device),\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 isPaired(identity: TailscaleIdentity, token: string): Promise<boolean> {\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.some((p) => p.identity === identity.loginName && timingSafeEqualStr(p.tokenHmac, tokenHmac));\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({ pairings, consumedPairingWindows });\n\t\t}\n\t\treturn { pairings, consumedPairingWindows };\n\t}\n}\n"]}
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Dashboard auth — exactly two modes (this file is the authority):
3
+ *
4
+ * Mode A — local-only (default): the server binds loopback only. Requests are
5
+ * additionally checked for loopback source address AND an allowlisted Host
6
+ * header (DNS-rebinding defense: a malicious website can point its own domain
7
+ * at 127.0.0.1 and drive the API from the victim's browser unless Host is
8
+ * validated). No login, no pairing.
9
+ *
10
+ * Mode B — remote (explicit opt-in): requires Tailscale. Enforcement layers,
11
+ * all fail-closed: (1) Tailscale identity resolution of the peer address,
12
+ * (2) identity allowlist (empty allowlist = deny all), (3) first-login
13
+ * rotating pairing code (visible only from the host/local dashboard),
14
+ * (4) signed per-device cookie thereafter.
15
+ *
16
+ * There is no LAN mode. Any auth-subsystem error denies the request.
17
+ */
18
+ import { execFile } from "node:child_process";
19
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
20
+ import { promisify } from "node:util";
21
+ const execFileAsync = promisify(execFile);
22
+ const PAIRING_CODE_STEP_MS = 30_000;
23
+ const DEFAULT_PAIRING_MAX_ATTEMPTS = 5;
24
+ const DEFAULT_PAIRING_LOCKOUT_MS = 60_000;
25
+ // ---------------------------------------------------------------------------
26
+ // Address / Host helpers
27
+ // ---------------------------------------------------------------------------
28
+ /** Normalize an address for comparison (strip IPv6-mapped IPv4 prefix and zone). */
29
+ export function normalizeAddress(address) {
30
+ if (!address)
31
+ return "";
32
+ let a = address.trim();
33
+ if (a.startsWith("[") && a.endsWith("]"))
34
+ a = a.slice(1, -1);
35
+ const zone = a.indexOf("%");
36
+ if (zone !== -1)
37
+ a = a.slice(0, zone);
38
+ if (a.startsWith("::ffff:"))
39
+ a = a.slice(7);
40
+ return a;
41
+ }
42
+ /** True when the (normalized) address is a loopback address. */
43
+ export function isLoopbackAddress(address) {
44
+ const a = normalizeAddress(address);
45
+ if (!a)
46
+ return false;
47
+ if (a === "::1")
48
+ return true;
49
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a);
50
+ }
51
+ /**
52
+ * Validate a Host header against the loopback allowlist. Rejecting foreign
53
+ * hosts breaks DNS rebinding: the attacker's page can reach 127.0.0.1, but its
54
+ * requests carry the attacker's hostname in Host.
55
+ */
56
+ export function isAllowedLocalHost(hostHeader) {
57
+ if (!hostHeader)
58
+ return false;
59
+ // Strip port. IPv6 hosts arrive as "[::1]:port".
60
+ let host = hostHeader.trim().toLowerCase();
61
+ const v6 = host.match(/^\[([^\]]+)\](?::\d+)?$/);
62
+ if (v6) {
63
+ host = v6[1];
64
+ }
65
+ else {
66
+ const colon = host.lastIndexOf(":");
67
+ if (colon !== -1 && /^\d+$/.test(host.slice(colon + 1)))
68
+ host = host.slice(0, colon);
69
+ }
70
+ return host === "localhost" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
71
+ }
72
+ /** Resolves identities via `tailscale status --json`. Any failure resolves null (deny). */
73
+ export class TailscaleStatusResolver {
74
+ async resolve(address) {
75
+ const target = normalizeAddress(address);
76
+ if (!target)
77
+ return null;
78
+ let status;
79
+ try {
80
+ const { stdout } = await execFileAsync("tailscale", ["status", "--json"], {
81
+ timeout: 3000,
82
+ maxBuffer: 4 * 1024 * 1024,
83
+ });
84
+ status = JSON.parse(stdout);
85
+ }
86
+ catch {
87
+ // Tailscale absent, not running, or unparseable — fail closed.
88
+ return null;
89
+ }
90
+ const peers = Object.values(status.Peer ?? {});
91
+ if (status.Self)
92
+ peers.push(status.Self);
93
+ for (const peer of peers) {
94
+ if (!peer.TailscaleIPs?.some((ip) => normalizeAddress(ip) === target))
95
+ continue;
96
+ const userId = peer.UserID;
97
+ const loginName = userId !== undefined ? status.User?.[String(userId)]?.LoginName : undefined;
98
+ if (!loginName)
99
+ return null; // identity unknown — deny rather than guess
100
+ return { loginName, device: peer.HostName };
101
+ }
102
+ return null;
103
+ }
104
+ }
105
+ /** In-memory storage — used in tests and as the base for the file store. */
106
+ export class MemoryPairingStorage {
107
+ state = { pairings: [], consumedPairingWindows: [] };
108
+ async load() {
109
+ return {
110
+ pairings: [...this.state.pairings],
111
+ consumedPairingWindows: [...this.state.consumedPairingWindows],
112
+ };
113
+ }
114
+ async save(state) {
115
+ this.state = {
116
+ pairings: [...state.pairings],
117
+ consumedPairingWindows: [...state.consumedPairingWindows],
118
+ };
119
+ }
120
+ }
121
+ function timingSafeEqualStr(a, b) {
122
+ const ab = Buffer.from(a);
123
+ const bb = Buffer.from(b);
124
+ if (ab.length !== bb.length)
125
+ return false;
126
+ return timingSafeEqual(ab, bb);
127
+ }
128
+ export class DashboardAuth {
129
+ remoteEnabled;
130
+ allowedIdentities;
131
+ pairingTtlMs;
132
+ resolver;
133
+ storage;
134
+ secret;
135
+ pairingMaxAttempts;
136
+ pairingLockoutMs;
137
+ logger;
138
+ now;
139
+ pairingMutation = Promise.resolve();
140
+ pairingFailures = new Map();
141
+ constructor(options = {}) {
142
+ this.remoteEnabled = options.remoteEnabled ?? false;
143
+ this.allowedIdentities = new Set(options.allowedIdentities ?? []);
144
+ this.pairingTtlMs = options.pairingTtlMs ?? 30 * 24 * 60 * 60 * 1000;
145
+ this.resolver = options.resolver ?? new TailscaleStatusResolver();
146
+ this.storage = options.storage ?? new MemoryPairingStorage();
147
+ this.secret = options.secret ?? randomBytes(32);
148
+ this.pairingMaxAttempts = options.pairingMaxAttempts ?? DEFAULT_PAIRING_MAX_ATTEMPTS;
149
+ this.pairingLockoutMs = options.pairingLockoutMs ?? DEFAULT_PAIRING_LOCKOUT_MS;
150
+ this.logger = options.logger ?? (() => { });
151
+ this.now = options.now ?? Date.now;
152
+ }
153
+ get isRemoteEnabled() {
154
+ return this.remoteEnabled;
155
+ }
156
+ hmac(value) {
157
+ return createHmac("sha256", this.secret).update(value).digest("hex");
158
+ }
159
+ pairingCodeForWindow(window) {
160
+ const counter = Buffer.alloc(8);
161
+ counter.writeBigUInt64BE(BigInt(window));
162
+ const digest = createHmac("sha1", this.secret).update(counter).digest();
163
+ const offset = digest[digest.length - 1] & 0x0f;
164
+ const value = ((digest[offset] & 0x7f) << 24) |
165
+ ((digest[offset + 1] & 0xff) << 16) |
166
+ ((digest[offset + 2] & 0xff) << 8) |
167
+ (digest[offset + 3] & 0xff);
168
+ return String(value % 1_000_000).padStart(6, "0");
169
+ }
170
+ /** Current RFC-6238-style rotating code for pairing new remote devices. */
171
+ currentPairingCode() {
172
+ const nowMs = this.now();
173
+ const window = Math.floor(nowMs / PAIRING_CODE_STEP_MS);
174
+ const expiresInMs = (window + 1) * PAIRING_CODE_STEP_MS - nowMs;
175
+ return { code: this.pairingCodeForWindow(window), expiresInMs };
176
+ }
177
+ currentPairingWindow() {
178
+ return Math.floor(this.now() / PAIRING_CODE_STEP_MS);
179
+ }
180
+ matchingPairingCodeWindow(code) {
181
+ if (!/^\d{6}$/.test(code))
182
+ return undefined;
183
+ const window = this.currentPairingWindow();
184
+ for (const candidateWindow of [window - 1, window, window + 1]) {
185
+ if (candidateWindow < 0)
186
+ continue;
187
+ if (timingSafeEqualStr(code, this.pairingCodeForWindow(candidateWindow)))
188
+ return candidateWindow;
189
+ }
190
+ return undefined;
191
+ }
192
+ pruneConsumedPairingWindows(windows) {
193
+ const minimumAcceptedWindow = Math.max(0, this.currentPairingWindow() - 1);
194
+ return [
195
+ ...new Set([...windows].filter((window) => Number.isSafeInteger(window) && window >= minimumAcceptedWindow)),
196
+ ].sort((a, b) => a - b);
197
+ }
198
+ samePairingWindows(a, b) {
199
+ return a.length === b.length && a.every((window, index) => window === b[index]);
200
+ }
201
+ pairingFailureKey(identity, remoteAddress) {
202
+ return `${identity.loginName}|${normalizeAddress(remoteAddress)}`;
203
+ }
204
+ assertPairingNotLocked(key) {
205
+ const failure = this.pairingFailures.get(key);
206
+ if (!failure?.lockedUntil)
207
+ return;
208
+ if (failure.lockedUntil > this.now()) {
209
+ throw Object.assign(new Error("Too many incorrect pairing attempts; try again later"), { status: 429 });
210
+ }
211
+ this.pairingFailures.delete(key);
212
+ }
213
+ recordPairingFailure(key, identity) {
214
+ const current = this.pairingFailures.get(key);
215
+ const count = (current?.count ?? 0) + 1;
216
+ if (count >= this.pairingMaxAttempts) {
217
+ const lockedUntil = this.now() + this.pairingLockoutMs;
218
+ this.pairingFailures.set(key, { count, lockedUntil });
219
+ this.logger(`pairing locked after ${count} failed attempts for ${identity.loginName} until ${new Date(lockedUntil).toISOString()}`);
220
+ throw Object.assign(new Error("Too many incorrect pairing attempts; try again later"), { status: 429 });
221
+ }
222
+ this.pairingFailures.set(key, { count, lockedUntil: current?.lockedUntil });
223
+ if (count > 1)
224
+ this.logger(`pairing failed attempt ${count} for ${identity.loginName}`);
225
+ throw Object.assign(new Error("Incorrect pairing code"), { status: 401 });
226
+ }
227
+ clearPairingFailures(key) {
228
+ this.pairingFailures.delete(key);
229
+ }
230
+ async withPairingMutation(fn) {
231
+ const previous = this.pairingMutation;
232
+ let release;
233
+ this.pairingMutation = new Promise((resolve) => {
234
+ release = resolve;
235
+ });
236
+ await previous;
237
+ try {
238
+ return await fn();
239
+ }
240
+ finally {
241
+ release();
242
+ }
243
+ }
244
+ /**
245
+ * Authenticate a request. Fail-closed: any resolver/storage error results in
246
+ * a deny, never a pass-through.
247
+ */
248
+ async authenticate(info) {
249
+ try {
250
+ return await this.authenticateInner(info);
251
+ }
252
+ catch (err) {
253
+ return {
254
+ allowed: false,
255
+ status: 500,
256
+ reason: `Auth subsystem error — denying: ${err instanceof Error ? err.message : String(err)}`,
257
+ };
258
+ }
259
+ }
260
+ async authenticateInner(info) {
261
+ if (isLoopbackAddress(info.remoteAddress)) {
262
+ if (!isAllowedLocalHost(info.hostHeader)) {
263
+ return {
264
+ allowed: false,
265
+ status: 403,
266
+ reason: `Host header "${info.hostHeader ?? "(missing)"}" is not a loopback host — rejected (DNS-rebinding defense)`,
267
+ };
268
+ }
269
+ if (info.originHeader) {
270
+ let originHost;
271
+ try {
272
+ originHost = new URL(info.originHeader).host;
273
+ }
274
+ catch {
275
+ originHost = undefined;
276
+ }
277
+ if (!originHost || !isAllowedLocalHost(originHost)) {
278
+ return {
279
+ allowed: false,
280
+ status: 403,
281
+ reason: `Origin "${info.originHeader}" is not a loopback origin — rejected (cross-site defense)`,
282
+ };
283
+ }
284
+ }
285
+ return { allowed: true, mode: "local" };
286
+ }
287
+ if (!this.remoteEnabled) {
288
+ return { allowed: false, status: 403, reason: "Remote dashboard access is disabled" };
289
+ }
290
+ const identity = await this.resolver.resolve(info.remoteAddress ?? "");
291
+ if (!identity) {
292
+ return { allowed: false, status: 403, reason: "Client is not a known Tailscale peer" };
293
+ }
294
+ if (this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {
295
+ return {
296
+ allowed: false,
297
+ status: 403,
298
+ reason: `Tailscale identity "${identity.loginName}" is not on the dashboard allowlist`,
299
+ identity,
300
+ };
301
+ }
302
+ if (!info.deviceToken || !(await this.isPaired(identity, info.deviceToken))) {
303
+ return {
304
+ allowed: false,
305
+ status: 401,
306
+ reason: "Device is not paired — pairing code required",
307
+ needsPairing: true,
308
+ identity,
309
+ };
310
+ }
311
+ return { allowed: true, mode: "remote", identity };
312
+ }
313
+ /**
314
+ * Complete pairing for an allowed remote identity using the current rotating
315
+ * code. Returns the device token to set as a cookie. Throws (with `status`) on
316
+ * any failure.
317
+ */
318
+ async pair(info, code) {
319
+ if (isLoopbackAddress(info.remoteAddress)) {
320
+ throw Object.assign(new Error("Loopback clients do not pair"), { status: 400 });
321
+ }
322
+ if (!this.remoteEnabled) {
323
+ throw Object.assign(new Error("Remote dashboard access is disabled"), { status: 403 });
324
+ }
325
+ const identity = await this.resolver.resolve(info.remoteAddress ?? "");
326
+ if (!identity || this.allowedIdentities.size === 0 || !this.allowedIdentities.has(identity.loginName)) {
327
+ throw Object.assign(new Error("Identity is not on the dashboard allowlist"), { status: 403 });
328
+ }
329
+ const failureKey = this.pairingFailureKey(identity, info.remoteAddress);
330
+ this.assertPairingNotLocked(failureKey);
331
+ return this.withPairingMutation(async () => {
332
+ const state = await this.loadLiveState();
333
+ const matchedWindow = this.matchingPairingCodeWindow(code);
334
+ if (matchedWindow === undefined || state.consumedPairingWindows.includes(matchedWindow)) {
335
+ this.recordPairingFailure(failureKey, identity);
336
+ }
337
+ const token = randomBytes(32).toString("base64url");
338
+ const nowMs = this.now();
339
+ const device = {
340
+ id: randomBytes(8).toString("hex"),
341
+ identity: identity.loginName,
342
+ device: identity.device,
343
+ createdAt: new Date(nowMs).toISOString(),
344
+ expiresAt: new Date(nowMs + this.pairingTtlMs).toISOString(),
345
+ };
346
+ const pairings = [...state.pairings, { ...device, tokenHmac: this.hmac(token) }];
347
+ const consumedPairingWindows = this.pruneConsumedPairingWindows([
348
+ ...state.consumedPairingWindows,
349
+ matchedWindow,
350
+ ]);
351
+ await this.storage.save({ pairings, consumedPairingWindows });
352
+ this.clearPairingFailures(failureKey);
353
+ return { token, device };
354
+ });
355
+ }
356
+ /** List paired devices (live only). */
357
+ async listDevices() {
358
+ return this.withPairingMutation(async () => (await this.loadLive()).map(({ tokenHmac: _tokenHmac, ...device }) => device));
359
+ }
360
+ /** Remove a paired device by id. Returns true when something was removed. */
361
+ async unpair(deviceId) {
362
+ return this.withPairingMutation(async () => {
363
+ const state = await this.loadLiveState();
364
+ const remaining = state.pairings.filter((p) => p.id !== deviceId);
365
+ if (remaining.length === state.pairings.length)
366
+ return false;
367
+ await this.storage.save({ ...state, pairings: remaining });
368
+ return true;
369
+ });
370
+ }
371
+ async isPaired(identity, token) {
372
+ const tokenHmac = this.hmac(token);
373
+ return this.withPairingMutation(async () => {
374
+ const live = await this.loadLive();
375
+ return live.some((p) => p.identity === identity.loginName && timingSafeEqualStr(p.tokenHmac, tokenHmac));
376
+ });
377
+ }
378
+ /** Load pairings, dropping (and persisting the removal of) expired entries. Caller must hold pairingMutation. */
379
+ async loadLive(saveExpiredRemoval = true) {
380
+ return (await this.loadLiveState(saveExpiredRemoval)).pairings;
381
+ }
382
+ /** Load pairing state, dropping expired devices and consumed PIN windows that can no longer match. */
383
+ async loadLiveState(savePrunedState = true) {
384
+ const state = await this.storage.load();
385
+ const nowMs = this.now();
386
+ const pairings = state.pairings.filter((p) => new Date(p.expiresAt).getTime() > nowMs);
387
+ const consumedPairingWindows = this.pruneConsumedPairingWindows(state.consumedPairingWindows);
388
+ if (savePrunedState &&
389
+ (pairings.length !== state.pairings.length ||
390
+ !this.samePairingWindows(consumedPairingWindows, state.consumedPairingWindows))) {
391
+ await this.storage.save({ pairings, consumedPairingWindows });
392
+ }
393
+ return { pairings, consumedPairingWindows };
394
+ }
395
+ }
396
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","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 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 TailscaleStatusPeer {\n\tTailscaleIPs?: string[];\n\tHostName?: string;\n\tUserID?: number;\n}\n\ninterface TailscaleStatusJson {\n\tSelf?: TailscaleStatusPeer & { UserID?: number };\n\tPeer?: Record<string, TailscaleStatusPeer>;\n\tUser?: Record<string, { LoginName?: string }>;\n}\n\n/** Resolves identities via `tailscale status --json`. Any failure resolves null (deny). */\nexport class TailscaleStatusResolver implements TailscaleResolver {\n\tasync resolve(address: string): Promise<TailscaleIdentity | null> {\n\t\tconst target = normalizeAddress(address);\n\t\tif (!target) return null;\n\t\tlet status: TailscaleStatusJson;\n\t\ttry {\n\t\t\tconst { stdout } = await execFileAsync(\"tailscale\", [\"status\", \"--json\"], {\n\t\t\t\ttimeout: 3000,\n\t\t\t\tmaxBuffer: 4 * 1024 * 1024,\n\t\t\t});\n\t\t\tstatus = JSON.parse(stdout) as TailscaleStatusJson;\n\t\t} catch {\n\t\t\t// Tailscale absent, not running, or unparseable — fail closed.\n\t\t\treturn null;\n\t\t}\n\t\tconst peers = Object.values(status.Peer ?? {});\n\t\tif (status.Self) peers.push(status.Self);\n\t\tfor (const peer of peers) {\n\t\t\tif (!peer.TailscaleIPs?.some((ip) => normalizeAddress(ip) === target)) continue;\n\t\t\tconst userId = peer.UserID;\n\t\t\tconst loginName = userId !== undefined ? status.User?.[String(userId)]?.LoginName : undefined;\n\t\t\tif (!loginName) return null; // identity unknown — deny rather than guess\n\t\t\treturn { loginName, device: peer.HostName };\n\t\t}\n\t\treturn null;\n\t}\n}\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}\n\nexport interface PairingState {\n\tpairings: StoredPairing[];\n\tconsumedPairingWindows: number[];\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],\n\t\t\tconsumedPairingWindows: [...this.state.consumedPairingWindows],\n\t\t};\n\t}\n\tasync save(state: PairingState): Promise<void> {\n\t\tthis.state = {\n\t\t\tpairings: [...state.pairings],\n\t\t\tconsumedPairingWindows: [...state.consumedPairingWindows],\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/** Device pairing validity. Default 30 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 }\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 pairingTtlMs: 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.pairingTtlMs = options.pairingTtlMs ?? 30 * 24 * 60 * 60 * 1000;\n\t\tthis.resolver = options.resolver ?? new TailscaleStatusResolver();\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 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\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\tif (!info.deviceToken || !(await this.isPaired(identity, info.deviceToken))) {\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 };\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 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 + this.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({ pairings, consumedPairingWindows });\n\t\t\tthis.clearPairingFailures(failureKey);\n\t\t\treturn { token, device };\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(({ tokenHmac: _tokenHmac, ...device }) => device),\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 isPaired(identity: TailscaleIdentity, token: string): Promise<boolean> {\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.some((p) => p.identity === identity.loginName && timingSafeEqualStr(p.tokenHmac, tokenHmac));\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({ pairings, consumedPairingWindows });\n\t\t}\n\t\treturn { pairings, consumedPairingWindows };\n\t}\n}\n"]}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * SSE event hub — fans runtime events out to connected browsers with a bounded
3
+ * ring buffer per hub for Last-Event-ID catch-up after reconnects.
4
+ */
5
+ import type { EventEnvelope } from "../shared/protocol.js";
6
+ export interface SseClient {
7
+ write(chunk: string): void;
8
+ }
9
+ export declare class EventHub {
10
+ private readonly bufferSize;
11
+ private seq;
12
+ private readonly buffer;
13
+ private readonly clients;
14
+ constructor(bufferSize?: number);
15
+ /** Publish an event from a runtime; assigns a sequence number and fans out. */
16
+ publish(key: string, event: Record<string, unknown>): EventEnvelope;
17
+ /**
18
+ * Attach a client. When `lastEventId` is provided, buffered events after it
19
+ * are replayed first. Returns a detach function.
20
+ *
21
+ * When the requested id has already been evicted from the buffer, or belongs
22
+ * to an older server instance whose sequence is no longer present, a
23
+ * `dashboard_resync` event is sent first — the client must refetch state
24
+ * because the gap cannot be replayed.
25
+ */
26
+ attach(client: SseClient, lastEventId?: number): () => void;
27
+ get clientCount(): number;
28
+ }
29
+ /** Format an envelope as an SSE frame with the sequence number as event id. */
30
+ export declare function formatSseFrame(envelope: EventEnvelope): string;
31
+ //# sourceMappingURL=event-hub.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-hub.d.ts","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,MAAM,WAAW,SAAS;IACzB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAID,qBAAa,QAAQ;IAKR,OAAO,CAAC,QAAQ,CAAC,UAAU;IAJvC,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;IAC9C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAEhD,YAA6B,UAAU,SAAsB,EAAI;IAEjE,+EAA+E;IAC/E,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,aAAa,CAiBlE;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,IAAI,CAsB1D;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;CACD;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CAE9D","sourcesContent":["/**\n * SSE event hub — fans runtime events out to connected browsers with a bounded\n * ring buffer per hub for Last-Event-ID catch-up after reconnects.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport interface SseClient {\n\twrite(chunk: string): void;\n}\n\nconst DEFAULT_BUFFER_SIZE = 2000;\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate readonly buffer: EventEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\n\tconstructor(private readonly bufferSize = DEFAULT_BUFFER_SIZE) {}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, event: Record<string, unknown>): EventEnvelope {\n\t\tthis.seq += 1;\n\t\tconst envelope: EventEnvelope = { seq: this.seq, key, event };\n\t\tthis.buffer.push(envelope);\n\t\tif (this.buffer.length > this.bufferSize) {\n\t\t\tthis.buffer.splice(0, this.buffer.length - this.bufferSize);\n\t\t}\n\t\tconst frame = formatSseFrame(envelope);\n\t\tfor (const client of this.clients) {\n\t\t\ttry {\n\t\t\t\tclient.write(frame);\n\t\t\t} catch {\n\t\t\t\t// Dead connections are removed via their close handlers; a write\n\t\t\t\t// failure here must not break the loop for other clients.\n\t\t\t}\n\t\t}\n\t\treturn envelope;\n\t}\n\n\t/**\n\t * Attach a client. When `lastEventId` is provided, buffered events after it\n\t * are replayed first. Returns a detach function.\n\t *\n\t * When the requested id has already been evicted from the buffer, or belongs\n\t * to an older server instance whose sequence is no longer present, a\n\t * `dashboard_resync` event is sent first — the client must refetch state\n\t * because the gap cannot be replayed.\n\t */\n\tattach(client: SseClient, lastEventId?: number): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst oldest = this.buffer[0]?.seq;\n\t\t\tconst newest = this.buffer[this.buffer.length - 1]?.seq;\n\t\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest) {\n\t\t\t\tthis.seq += 1;\n\t\t\t\tclient.write(\n\t\t\t\t\tformatSseFrame({\n\t\t\t\t\t\tseq: this.seq,\n\t\t\t\t\t\tkey: \"\",\n\t\t\t\t\t\tevent: { type: \"dashboard_resync\", reason: oldest === undefined ? \"empty_buffer\" : \"buffer_gap\" },\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tfor (const envelope of this.buffer) {\n\t\t\t\tif (envelope.seq > lastEventId) client.write(formatSseFrame(envelope));\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n"]}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * SSE event hub — fans runtime events out to connected browsers with a bounded
3
+ * ring buffer per hub for Last-Event-ID catch-up after reconnects.
4
+ */
5
+ const DEFAULT_BUFFER_SIZE = 2000;
6
+ export class EventHub {
7
+ bufferSize;
8
+ seq = 0;
9
+ buffer = [];
10
+ clients = new Set();
11
+ constructor(bufferSize = DEFAULT_BUFFER_SIZE) {
12
+ this.bufferSize = bufferSize;
13
+ }
14
+ /** Publish an event from a runtime; assigns a sequence number and fans out. */
15
+ publish(key, event) {
16
+ this.seq += 1;
17
+ const envelope = { seq: this.seq, key, event };
18
+ this.buffer.push(envelope);
19
+ if (this.buffer.length > this.bufferSize) {
20
+ this.buffer.splice(0, this.buffer.length - this.bufferSize);
21
+ }
22
+ const frame = formatSseFrame(envelope);
23
+ for (const client of this.clients) {
24
+ try {
25
+ client.write(frame);
26
+ }
27
+ catch {
28
+ // Dead connections are removed via their close handlers; a write
29
+ // failure here must not break the loop for other clients.
30
+ }
31
+ }
32
+ return envelope;
33
+ }
34
+ /**
35
+ * Attach a client. When `lastEventId` is provided, buffered events after it
36
+ * are replayed first. Returns a detach function.
37
+ *
38
+ * When the requested id has already been evicted from the buffer, or belongs
39
+ * to an older server instance whose sequence is no longer present, a
40
+ * `dashboard_resync` event is sent first — the client must refetch state
41
+ * because the gap cannot be replayed.
42
+ */
43
+ attach(client, lastEventId) {
44
+ if (lastEventId !== undefined) {
45
+ const oldest = this.buffer[0]?.seq;
46
+ const newest = this.buffer[this.buffer.length - 1]?.seq;
47
+ if (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest) {
48
+ this.seq += 1;
49
+ client.write(formatSseFrame({
50
+ seq: this.seq,
51
+ key: "",
52
+ event: { type: "dashboard_resync", reason: oldest === undefined ? "empty_buffer" : "buffer_gap" },
53
+ }));
54
+ }
55
+ for (const envelope of this.buffer) {
56
+ if (envelope.seq > lastEventId)
57
+ client.write(formatSseFrame(envelope));
58
+ }
59
+ }
60
+ this.clients.add(client);
61
+ return () => {
62
+ this.clients.delete(client);
63
+ };
64
+ }
65
+ get clientCount() {
66
+ return this.clients.size;
67
+ }
68
+ }
69
+ /** Format an envelope as an SSE frame with the sequence number as event id. */
70
+ export function formatSseFrame(envelope) {
71
+ return `id: ${envelope.seq}\ndata: ${JSON.stringify(envelope)}\n\n`;
72
+ }
73
+ //# sourceMappingURL=event-hub.js.map