iobroker.hassemu 1.35.0 → 1.35.2

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.
package/README.md CHANGED
@@ -28,6 +28,14 @@ Typical clients: Shelly Wall Display family (built-in HA page; on-device HA app
28
28
 
29
29
  ---
30
30
 
31
+ ## Sentry / Error reporting
32
+
33
+ **This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** Reporting only happens if you have enabled error reporting in the ioBroker diagnostics (**System settings → Diagnostics and error reporting**). Only an anonymous installation ID is transmitted — no name, e-mail address or IP address.
34
+
35
+ For details and how to disable it, see the [Sentry plugin documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry). Error reporting requires js-controller 3.0 or newer.
36
+
37
+ ---
38
+
31
39
  ## Supported dashboards
32
40
 
33
41
  The mode dropdown auto-discovers what's installed on your ioBroker host. You always have the option to paste any other HTTP URL as `manual`.
@@ -47,7 +55,7 @@ Want to add a URL the adapter doesn't auto-detect? Set `manual` and paste it.
47
55
  ## Requirements
48
56
 
49
57
  - Node.js ≥ 22
50
- - ioBroker js-controller ≥ 7.0.7
58
+ - ioBroker js-controller ≥ 7.1.2
51
59
  - ioBroker Admin ≥ 7.8.23
52
60
 
53
61
  ---
@@ -146,18 +154,21 @@ Got scripts that still write to `visUrl`? Update them — write to `manualUrl` i
146
154
 
147
155
  ---
148
156
 
149
- ## Sentry / Error reporting
150
-
151
- This adapter uses [Sentry](https://sentry.io) to automatically report exceptions and errors to the developer, so problems can be found and fixed quickly. Reporting only happens if you have enabled error reporting in the ioBroker diagnostics (**System settings → Diagnostics and error reporting**). Only an anonymous installation ID is transmitted — no name, e-mail address or IP address.
152
-
153
- For details and how to disable it, see the [Sentry plugin documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry). Error reporting requires js-controller 3.0 or newer.
154
-
155
157
  ## Changelog
156
158
 
157
159
  <!--
158
160
  Placeholder for the next version (at the beginning of the line):
159
161
  ### **WORK IN PROGRESS**
160
162
  -->
163
+ ### 1.35.2 (2026-06-12)
164
+
165
+ - Displays whose registration became stale after an adapter restart now re-register automatically — the server previously answered in a way the companion app did not recognize as "please register again"
166
+ - Removing a display now also clears its leftover app registration, so a re-added display starts with a fresh one
167
+
168
+ ### 1.35.1 (2026-06-09)
169
+
170
+ - Internal cleanup. No user-facing changes.
171
+
161
172
  ### 1.35.0 (2026-06-07)
162
173
 
163
174
  - Added optional Sentry error reporting: crashes are sent to the developer so issues get fixed faster. Active only with ioBroker diagnostics enabled; anonymous.
@@ -170,14 +181,6 @@ For details and how to disable it, see the [Sentry plugin documentation](https:/
170
181
 
171
182
  - Changelog rewritten in user-centric style across all versions.
172
183
 
173
- ### 1.33.1 (2026-05-23)
174
-
175
- - Fixed incorrect role assignment on global mode selector.
176
-
177
- ### 1.33.0 (2026-05-22)
178
-
179
- - User-modified state names are no longer overwritten on adapter restart
180
-
181
184
  [Older changelogs can be found there](CHANGELOG_OLD.md)
182
185
 
183
186
  ## Support Development
@@ -346,7 +346,7 @@ class ClientRegistry {
346
346
  /**
347
347
  * Set every client's `mode` to the same value. Used by the master switch
348
348
  * (`global.enabled`) to bulk-sync all displays — `'global'` when on,
349
- * the first discovered URL when off.
349
+ * `'0'` (no-choice landing page) when off.
350
350
  *
351
351
  * Skips clients whose mode already matches (no spurious state writes).
352
352
  *
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/client-registry.ts"],
4
- "sourcesContent": ["/**\n * Client Registry \u2014 persistent multi-client store.\n *\n * Each client gets a channel `clients.<id>` with native.cookie / native.token\n * and states mode / manualUrl / ip / remove. Cookie is the primary identity\n * (auto-sent by browsers on page navigation), IP is only advisory.\n *\n * Registry state is dual-homed: in-memory maps for hot lookups, ioBroker\n * objects for persistence and user-visible config.\n */\n\nimport crypto from \"node:crypto\";\nimport {\n buildDropdownStates,\n coerceSafeUrl,\n coerceString,\n coerceUuid,\n evictOldest,\n isPlainObject,\n parseAdapterStateId,\n parseManualUrlWrite,\n parseModeWrite,\n safeGetState,\n} from \"./coerce\";\nimport { MODE_GLOBAL, MODE_MANUAL, NEW_CLIENT_BURST_CAP } from \"./constants\";\nimport { resolveLabel, tName } from \"./i18n\";\nimport { generateClientId } from \"./network\";\nimport type { AdapterInterface, ClientRecord, UrlStates } from \"./types\";\n\n/** Extended adapter interface for registry \u2014 needs object and state operations. */\nexport type RegistryAdapter = AdapterInterface &\n Pick<\n ioBroker.Adapter,\n | \"namespace\"\n | \"getForeignObjectsAsync\"\n | \"getStateAsync\"\n | \"getObjectAsync\"\n | \"setObjectNotExistsAsync\"\n | \"setObjectAsync\"\n | \"extendObjectAsync\"\n | \"setStateAsync\"\n | \"delObjectAsync\"\n >;\n\nconst CLIENTS_PREFIX = \"clients.\";\n\n/** Provides the default mode value for a freshly created client. */\nexport type NewClientModeProvider = () => string;\n\n/** Persistent multi-client store: cookie \u2192 channel, with in-memory lookup maps. */\nexport class ClientRegistry {\n private readonly adapter: RegistryAdapter;\n private readonly byCookie = new Map<string, ClientRecord>();\n private readonly byId = new Map<string, ClientRecord>();\n private readonly byToken = new Map<string, ClientRecord>();\n private readonly byRefreshToken = new Map<string, ClientRecord>();\n private currentUrlStates: UrlStates = {};\n private newClientModeProvider: NewClientModeProvider = () => MODE_GLOBAL;\n /**\n * In-flight client creations keyed by remote IP. Keeps parallel cookieless\n * requests from the same display (typical on first connect: HA clients fire\n * `GET /`, `GET /api/`, `POST /auth/login_flow` almost simultaneously) from\n * each creating a separate client record. The first request starts the\n * create; parallel requests await the same Promise and receive the same\n * client + cookie.\n */\n private readonly pendingByIp = new Map<string, Promise<ClientRecord>>();\n /**\n * Throttle for lastSeen-updates per client. Keyed by client id, value is the\n * last `Date.now()` we wrote `native.lastSeen` to ioBroker. Throttle window\n * is one hour \u2014 saves us extendObject roundtrips on every request.\n */\n private readonly lastSeenFlushedAt = new Map<string, number>();\n /**\n * v1.19.0 (G5): per-IP burst tracking f\u00FCr broken-cookie-Displays.\n * Wenn eine IP > 3 neue Clients in einer Stunde erzeugt, kommt ein\n * einmaliger warn-log mit Hinweis (cookie-Persistenz auf Display kaputt).\n */\n private readonly newClientBurst = new Map<string, { count: number; since: number; warnedAt: number }>();\n\n /** @param adapter Adapter instance used for object/state I/O. */\n constructor(adapter: RegistryAdapter) {\n this.adapter = adapter;\n }\n\n /**\n * Wires the default-mode provider used when a new client is registered.\n * Called from main.ts once registry, globalConfig and urlDiscovery exist.\n *\n * @param provider Function returning the desired default mode for a new client.\n */\n setNewClientModeProvider(provider: NewClientModeProvider): void {\n this.newClientModeProvider = provider;\n }\n\n /** Loads existing clients from ioBroker objects into memory. Call once on adapter start. */\n async restore(): Promise<void> {\n let channels: Record<string, ioBroker.ChannelObject> = {};\n try {\n channels = (await this.adapter.getForeignObjectsAsync(`${this.adapter.namespace}.clients.*`, \"channel\")) ?? {};\n } catch (err) {\n this.adapter.log.debug(`client-registry: restore failed: ${String(err)}`);\n return;\n }\n\n for (const [fullId, obj] of Object.entries(channels)) {\n const id = fullId.substring(`${this.adapter.namespace}.clients.`.length);\n if (!id || id.includes(\".\")) {\n continue;\n }\n // v1.28.3 (HE1): per-client try/catch \u2014 bei Promise.all auf 4\n // readState-Calls reicht ein einzelner Reject (z.B. corrupted state\n // bei migrating jsonl-store), und ALLE folgenden Clients werden\n // nicht restored. Pro-Client-Wrap entkoppelt: ein broken Client\n // kostet nur sich selbst.\n try {\n const native = isPlainObject(obj.native) ? obj.native : {};\n const cookie = coerceUuid(native.cookie);\n if (!cookie) {\n continue;\n }\n // v1.9.0 (D8): vier readState-Calls parallel statt sequenziell.\n // Mit 50 Clients waren das vorher 200 sequenzielle Round-Trips\n // bevor der WebServer up war; jetzt 50 parallele 4er-Gruppen.\n const [modeRaw, manualUrlRaw, ipRaw, hostnameRaw] = await Promise.all([\n this.readState(`${id}.mode`),\n this.readState(`${id}.manualUrl`),\n this.readState(`${id}.ip`),\n this.readState(`${id}.hostname`),\n ]);\n const mode = typeof modeRaw === \"string\" ? modeRaw : \"\";\n const manualUrl = coerceSafeUrl(manualUrlRaw);\n const ip = coerceString(ipRaw);\n const token = coerceUuid(native.token);\n const refreshToken = coerceUuid(native.refreshToken);\n\n // Legacy migration (<=1.1.1): hostname lived in its own state. If present,\n // move the value into common.name and drop the state.\n const legacyHostname = coerceString(hostnameRaw);\n let channelName = coerceString(obj.common?.name);\n if (legacyHostname) {\n this.adapter.log.debug(\n `restore: legacy hostname migration for client ${id} \u2014 '${legacyHostname}' moved to common.name`,\n );\n if (legacyHostname !== channelName) {\n await this.adapter.extendObjectAsync(`clients.${id}`, { common: { name: legacyHostname } });\n channelName = legacyHostname;\n }\n try {\n await this.adapter.delObjectAsync(`clients.${id}.hostname`);\n } catch {\n /* best effort \u2014 ignore */\n }\n }\n const hostname = channelName && channelName !== ip && channelName !== id ? channelName : null;\n\n const record: ClientRecord = { id, cookie, token, refreshToken, mode, manualUrl, ip, hostname };\n this.trackInMemory(record);\n // Legacy clients (v1.1.x) only had `visUrl` + `ip` + `remove` objects;\n // ensure the v1.2.0+ objects (`mode`, `manualUrl`) exist before any\n // state writes from migration land \u2014 otherwise js-controller logs\n // \"State has no existing object\" warnings.\n await this.ensureObjects(record);\n // Promote blank state-value to numeric 0 so the dropdown renders\n // the `0='---'` option as selected. v1.2.0 installs left the value\n // as `''` which doesn't match any common.states entry.\n const modeStateRaw = await this.readState(`${id}.mode`);\n if (modeStateRaw === \"\" || modeStateRaw === null || modeStateRaw === undefined) {\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n }\n } catch (err) {\n this.adapter.log.debug(`client-registry: skipping ${id} during restore \u2014 ${String(err)}`);\n }\n }\n this.adapter.log.debug(`client-registry: restored ${this.byId.size} client(s)`);\n }\n\n /**\n * Find the client for this cookie or create a new one.\n * Creates channel + states on first call and updates IP/hostname if changed.\n *\n * @param cookie Incoming cookie value (may be null/invalid).\n * @param ip Remote IP observed by the server.\n * @param hostname Optional hostname (from reverse DNS), stored for the admin UI.\n * @param userAgent Optional User-Agent header for NAT-collision-Schutz im Pending-Lock.\n */\n async identifyOrCreate(\n cookie: string | null,\n ip: string | null,\n hostname: string | null,\n userAgent: string | null = null,\n ): Promise<ClientRecord> {\n const validCookie = coerceUuid(cookie);\n if (validCookie) {\n const existing = this.byCookie.get(validCookie);\n if (existing) {\n await this.updateIpHostname(existing, ip, hostname);\n this.touchLastSeen(existing);\n return existing;\n }\n }\n // No valid cookie: before spinning up a new client, check whether this\n // IP already has a create in flight. If so, await that Promise \u2014 the\n // parallel request of the same display's initial burst will get the\n // same cookie + client, no more duplicate \"New client\" log entries.\n //\n // v1.17.0 (C8): Bucket-Key kombiniert IP + User-Agent-Hash, sodass\n // zwei verschiedene Displays hinter derselben NAT-IP NICHT in denselben\n // Pending-Lock fallen (vorher: gleicher Cookie/Token/Mode \u2192 Cookie-\n // Klau-Vektor). UA-Hash truncated auf 12 Hex-Chars um Memory-Footprint\n // klein zu halten. Bei UA=null f\u00E4llt der Bucket auf reines IP zur\u00FCck.\n if (ip) {\n const bucketKey = userAgent\n ? `${ip}|${crypto.createHash(\"sha256\").update(userAgent).digest(\"hex\").substring(0, 12)}`\n : ip;\n const pending = this.pendingByIp.get(bucketKey);\n if (pending) {\n // v1.21.0 (D3): pending-promise kann rejecten \u2014 z.B. wenn\n // createClient async failed (broker-disconnect, object-create-\n // error). Wir k\u00F6nnen nicht recover'n (der erste Caller hat eh\n // schon gefailt), aber wir wollen den Fehler diagnostizierbar\n // machen. catch+rethrow sorgt f\u00FCr ein einzelnes log statt\n // unhandled-rejection im fastify-error-handler.\n return pending.catch(err => {\n this.adapter.log.debug(`client-registry: pending createClient for ${bucketKey} rejected: ${String(err)}`);\n throw err;\n });\n }\n const promise = this.createClient(ip, hostname);\n this.pendingByIp.set(bucketKey, promise);\n try {\n return await promise;\n } catch (err) {\n // Tech-Diagnose mit Stack-Detail \u2014 bleibt debug (Maintainer-only).\n this.adapter.log.debug(\n `client-registry: createClient failed for IP ${ip}: ${err instanceof Error ? err.message : String(err)}`,\n );\n throw err;\n } finally {\n this.pendingByIp.delete(bucketKey);\n }\n }\n return this.createClient(ip, hostname);\n }\n\n /**\n * Lookup by short client id (channel segment).\n *\n * @param id Client id.\n */\n getById(id: string): ClientRecord | null {\n return this.byId.get(id) ?? null;\n }\n\n /**\n * Lookup by cookie value. Invalid UUIDs return null.\n *\n * @param cookie Raw cookie string.\n */\n getByCookie(cookie: string): ClientRecord | null {\n const v = coerceUuid(cookie);\n return v ? (this.byCookie.get(v) ?? null) : null;\n }\n\n /**\n * Lookup by access token issued during the auth flow.\n *\n * @param token Bearer token.\n */\n getByToken(token: string): ClientRecord | null {\n return this.byToken.get(token) ?? null;\n }\n\n /**\n * Lookup by refresh token issued during the auth flow.\n *\n * @param refreshToken Refresh token value.\n */\n getByRefreshToken(refreshToken: string): ClientRecord | null {\n return this.byRefreshToken.get(refreshToken) ?? null;\n }\n\n /** Returns a snapshot array of all registered clients. */\n listAll(): ClientRecord[] {\n return [...this.byId.values()];\n }\n\n /**\n * Updates in-memory token and persists to channel.native. Old token is freed.\n *\n * @param id Client id.\n * @param token New bearer token, or null to clear.\n */\n async setToken(id: string, token: string | null): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n if (record.token) {\n this.byToken.delete(record.token);\n }\n record.token = token;\n if (token) {\n this.byToken.set(token, record);\n }\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { token } });\n }\n\n /**\n * Updates in-memory refresh token and persists to channel.native. Old refresh\n * token is freed. Stored plain-text in `clients.<id>.native.refreshToken` \u2014\n * same exposure profile as the access token (see {@link ClientRecord.refreshToken}).\n *\n * @param id Client id.\n * @param refreshToken New refresh token, or null to clear.\n */\n async setRefreshToken(id: string, refreshToken: string | null): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n if (record.refreshToken) {\n this.byRefreshToken.delete(record.refreshToken);\n }\n record.refreshToken = refreshToken;\n if (refreshToken) {\n this.byRefreshToken.set(refreshToken, record);\n }\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { refreshToken } });\n }\n\n /**\n * Accept an external mode write on `clients.<id>.mode`.\n *\n * Allowed values: `'global'`, `'manual'`, or any URL that passes\n * {@link coerceSafeUrl}. Empty string clears the choice \u2192 setup page.\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleModeWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n // v1.23.0 (F2): zentralisierte Validierung via parseModeWrite. Vorher\n // hatten client-registry und global-config ~80% identische Logik\n // (no-choice, non-string, sentinel, URL-coerce) dupliziert.\n const result = parseModeWrite(rawValue, [MODE_GLOBAL, MODE_MANUAL]);\n switch (result.kind) {\n case \"no-choice\":\n record.mode = \"\";\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n this.adapter.log.debug(`Client ${id}: mode \u2192 cleared (no-choice)`);\n return;\n case \"rejected-non-string\":\n // v1.18.0 (G7): debug statt warn \u2014 nicht-string mode-Schreibungen\n // sind UI-Echo, kein Server-Concern.\n this.adapter.log.debug(`client-registry: rejected non-string mode for ${id}`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n return;\n case \"sentinel\":\n if (result.value === MODE_MANUAL && !record.manualUrl) {\n this.adapter.log.warn(\n `Client ${id}: mode set to \"manual\" but manualUrl is empty \u2014 fill clients.${id}.manualUrl to redirect`,\n );\n }\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n this.adapter.log.debug(`Client ${id}: mode \u2192 '${result.value}' (sentinel)`);\n return;\n case \"rejected-unsafe-url\":\n this.adapter.log.warn(`Client ${id}: rejected unsafe mode value \"${result.raw}\"`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode, ack: true });\n return;\n case \"url\":\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n this.adapter.log.debug(`Client ${id}: mode \u2192 ${result.value} (direct URL)`);\n return;\n // 'rejected-disallowed-sentinel' kommt hier nicht vor weil beide\n // Sentinels (global/manual) erlaubt sind. Defensive: revert.\n default:\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n }\n }\n\n /**\n * Accept an external manualUrl write on `clients.<id>.manualUrl`.\n * Free-text \u2014 must pass {@link coerceSafeUrl} or be empty (clears).\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleManualUrlWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n const result = parseManualUrlWrite(rawValue);\n if (!result.ok) {\n this.adapter.log.warn(`Client ${id}: rejected unsafe manualUrl value`);\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: record.manualUrl ?? \"\", ack: true });\n return;\n }\n record.manualUrl = result.safe;\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: result.safe ?? \"\", ack: true });\n this.adapter.log.debug(`Client ${id}: manualUrl \u2192 ${result.safe ?? \"cleared\"}`);\n if (record.mode === MODE_MANUAL && !result.safe) {\n this.adapter.log.warn(`Client ${id}: manualUrl cleared while mode is \"manual\" \u2014 display will see the setup page`);\n }\n }\n\n /**\n * Set every client's `mode` to the same value. Used by the master switch\n * (`global.enabled`) to bulk-sync all displays \u2014 `'global'` when on,\n * the first discovered URL when off.\n *\n * Skips clients whose mode already matches (no spurious state writes).\n *\n * @param value New mode value (sentinel or URL).\n */\n async bulkSetMode(value: string): Promise<void> {\n // v1.8.1 (D7): parallele setStateAsync statt sequenziell. Mit 50 Displays\n // war das vorher 50 Broker-Round-Trips. setStateAsync ist Broker-internal,\n // Parallelism ist safe.\n const writes: Array<Promise<unknown>> = [];\n let changed = 0;\n for (const record of this.byId.values()) {\n if (record.mode === value) {\n continue;\n }\n record.mode = value;\n writes.push(this.adapter.setStateAsync(`clients.${record.id}.mode`, { val: value, ack: true }));\n changed++;\n }\n if (writes.length > 0) {\n await Promise.all(writes);\n }\n if (changed > 0) {\n this.adapter.log.debug(`bulkSetMode applied to ${changed} client(s)`);\n }\n }\n\n /**\n * Removes the client entirely \u2014 channel + states deleted, next visit creates a new entry.\n *\n * @param id Client id to forget.\n */\n async remove(id: string): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n this.byId.delete(id);\n this.byCookie.delete(record.cookie);\n if (record.token) {\n this.byToken.delete(record.token);\n }\n if (record.refreshToken) {\n this.byRefreshToken.delete(record.refreshToken);\n }\n // v1.8.1 (D2): lastSeenFlushedAt war fr\u00FCher nicht aufger\u00E4umt \u2014 bei\n // ID-Reuse (16M-Space, m\u00F6glich nach 100+ Clients \u00FCber Jahre) h\u00E4tte\n // die alte Throttle-Entry den ersten lastSeen-Write des neuen Clients\n // inhibiert. Plus minimal Memory-Leak.\n this.lastSeenFlushedAt.delete(id);\n try {\n await this.adapter.delObjectAsync(`clients.${id}`, { recursive: true });\n } catch (err) {\n // Stack-trace level \u2014 Maintainer-Diagnose, EN bleibt.\n this.adapter.log.debug(`client-registry: delObject failed for ${id}: ${String(err)}`);\n }\n this.adapter.log.info(`Client forgotten: ${id}`);\n }\n\n /**\n * Updates the mode dropdown states (`common.states`) on every client's mode datapoint.\n * Adds the `'global'` and `'manual'` sentinels on top of the discovered URLs.\n *\n * @param states Discovered URL \u2192 label map.\n */\n async syncUrlDropdown(states: UrlStates): Promise<void> {\n this.currentUrlStates = states;\n const merged = this.buildModeStates();\n // v1.27.2: extendObjectAsync mergt `common.states` tief \u2014 alte\n // URL-Schl\u00FCssel die nicht mehr discovered werden, blieben sonst im\n // Dropdown stehen (sichtbar nach v1.26\u2192v1.27 URL-Format-Wechsel:\n // alte `vis-2.0/main/index.html`-Keys neben neuen `vis-2/index.html?main`).\n // Object lesen, common.states komplett ersetzen, dann setObjectAsync.\n // v1.30.0 (R4): pro-Client get+set parallel statt sequentiell. Analog\n // gcStaleClients in main.ts (v1.28.3 M5). Sp\u00FCrbar bei Display-Farmen\n // mit 30+ Clients \u2014 vorher 2\u00D7N Broker-Round-Trips sequenziell.\n await Promise.all(\n Array.from(this.byId.keys()).map(async id => {\n const stateId = `clients.${id}.mode`;\n const existing = await this.adapter.getObjectAsync(stateId);\n if (!existing) {\n return;\n }\n existing.common.states = merged;\n await this.adapter.setObjectAsync(stateId, existing);\n }),\n );\n }\n\n // --- internal ---\n\n private trackInMemory(record: ClientRecord): void {\n this.byId.set(record.id, record);\n this.byCookie.set(record.cookie, record);\n if (record.token) {\n this.byToken.set(record.token, record);\n }\n if (record.refreshToken) {\n this.byRefreshToken.set(record.refreshToken, record);\n }\n }\n\n private async createClient(ip: string | null, hostname: string | null): Promise<ClientRecord> {\n let id = generateClientId();\n while (this.byId.has(id)) {\n id = generateClientId();\n }\n const cookie = crypto.randomUUID();\n const mode = this.newClientModeProvider();\n const record: ClientRecord = {\n id,\n cookie,\n token: null,\n refreshToken: null,\n mode,\n manualUrl: null,\n ip,\n hostname,\n };\n this.trackInMemory(record);\n await this.createObjects(record);\n this.touchLastSeen(record);\n this.adapter.log.info(ip ? `New client connected: ${id} (${hostname ?? ip})` : `New client connected: ${id}`);\n // v1.19.0 (G5): IP-Burst-Detection f\u00FCr broken-cookie-Displays. Wenn\n // dieselbe IP > 3 neue Clients in einer Stunde erzeugt, ist der Cookie-\n // Mechanismus auf dem Display kaputt (aggressive Privacy, refresh-bug).\n // Einmaliger warn-Hinweis pro IP pro Stunde \u2014 danach bleibt der info-\n // log normal, aber der Operator hat wenigstens einen Anker zur Diagnose.\n if (ip) {\n this.recordNewClientIp(ip);\n }\n return record;\n }\n\n /**\n * v1.19.0 (G5): tracking-only \u2014 wenn eine IP > 3 neue Clients pro Stunde\n * erzeugt, einmaliger warn-log mit Diagnose-Hinweis. Danach 1h cooldown\n * pro IP. Map-Cap 200 (FIFO).\n *\n * @param ip Remote IP that just got a new ClientRecord assigned.\n */\n private recordNewClientIp(ip: string): void {\n const now = Date.now();\n const HOUR = 60 * 60 * 1000;\n const entry = this.newClientBurst.get(ip) ?? { count: 0, since: now, warnedAt: 0 };\n if (now - entry.since > HOUR) {\n // Window expired \u2014 reset.\n entry.count = 0;\n entry.since = now;\n }\n entry.count += 1;\n if (entry.count > 3 && now - entry.warnedAt > HOUR) {\n this.adapter.log.warn(\n `IP ${ip} created ${entry.count} clients within an hour \u2014 display likely is not persisting cookies (privacy mode? refresh bug?)`,\n );\n entry.warnedAt = now;\n }\n this.newClientBurst.set(ip, entry);\n // v1.32.0: Soft-Cap via shared `evictOldest` (vorher inline single-shot).\n evictOldest(this.newClientBurst, NEW_CLIENT_BURST_CAP);\n }\n\n /**\n * Updates `native.lastSeen` on the channel, throttled to once per hour per\n * client. Used for the stale-client-GC: clients without token + lastSeen\n * older than 30 days get auto-removed on adapter start.\n *\n * Fire-and-forget \u2014 failures only debug-logged.\n *\n * @param record Client whose lastSeen-timestamp should be refreshed.\n */\n private touchLastSeen(record: ClientRecord): void {\n const now = Date.now();\n const last = this.lastSeenFlushedAt.get(record.id) ?? 0;\n if (now - last < 60 * 60 * 1000) {\n return; // throttle: 1\u00D7 per hour\n }\n this.lastSeenFlushedAt.set(record.id, now);\n this.adapter\n .extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } })\n .catch(err => this.adapter.log.debug(`touchLastSeen failed for ${record.id}: ${String(err)}`));\n }\n\n /**\n * v1.19.0 (F11): zentraler lastSeen-Seed-Pfad. Vorher hatte main.ts\n * gcStaleClients seinen eigenen extendObjectAsync-Call mit identischem\n * native-Format \u2014 DRY-Violation und gef\u00E4hrlich wenn das Format mal \u00E4ndert.\n * Jetzt nutzen beide Pfade diese Methode. Throttle-Map wird auch upgedated,\n * damit der n\u00E4chste touchLastSeen den seed nicht direkt \u00FCberschreibt.\n *\n * @param id Client id (short segment, ohne `clients.`-Prefix).\n * @param now Optionaler Timestamp f\u00FCr tests; default Date.now().\n */\n async seedLastSeen(id: string, now: number = Date.now()): Promise<void> {\n this.lastSeenFlushedAt.set(id, now);\n try {\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { lastSeen: now } });\n } catch (err) {\n this.adapter.log.debug(`seedLastSeen failed for ${id}: ${String(err)}`);\n }\n }\n\n /**\n * Builds the dropdown-states map for `clients.<id>.mode`. Includes the\n * `0='---'` no-choice fallback (analogous to the govee-smart pattern), the\n * `'global'` + `'manual'` sentinels, and all currently discovered URLs.\n */\n private buildModeStates(): UrlStates {\n // v1.20.0 (F4): Helper aus coerce.ts. Vorher dupliziert mit global-config\n // (bis auf den zus\u00E4tzlichen `global`-Sentinel hier, weil clients per\n // `mode='global'` an global delegieren k\u00F6nnen \u2014 global selbst nicht).\n // v1.28.4: Sentinel-Labels als plain-string in adapter.systemLanguage \u2014\n // Admin rendert common.states-VALUES direkt als React-child und crasht\n // bei Translation-Objects mit React Error #31. v1.28.0 hatte einen\n // helper hier via `as unknown as string`-Cast eingeschleust, der den\n // Type-Check still lie\u00DF aber den Admin-Dropdown beim \u00D6ffnen zerlegt\n // hat. Memory `reference_common_states_plain_string_only`.\n return buildDropdownStates(\n {\n [MODE_GLOBAL]: resolveLabel(\"globalUrl\"),\n [MODE_MANUAL]: resolveLabel(\"manualUrl\"),\n },\n this.currentUrlStates,\n );\n }\n\n /**\n * Idempotently creates all per-client objects (channel + states). Safe to\n * call repeatedly \u2014 uses `setObjectNotExistsAsync` everywhere. Called from\n * both `restore()` (so legacy v1.1.x clients gain the new mode/manualUrl\n * objects before migration writes states) and `createClient()`.\n *\n * @param record Client to create or ensure objects for.\n */\n private async ensureObjects(record: ClientRecord): Promise<void> {\n const { id, cookie, ip, hostname } = record;\n const mergedStates = this.buildModeStates();\n\n // Channel: setObjectNotExistsAsync \u2014 common.name is updated dynamically\n // by updateIpHostname() when reverse-DNS resolves; we must not clobber it.\n await this.adapter.setObjectNotExistsAsync(`clients.${id}`, {\n type: \"channel\",\n common: { name: hostname ?? ip ?? id },\n native: { cookie, token: null },\n });\n\n // States:\n // - `clients.<id>.mode` uses get+setObjectAsync (analog `global-config.ts`\n // v1.27.2-Fix): extendObjectAsync deep-merges common.states, weshalb\n // alte i18n-Object-Keys aus pre-v1.28.4-Installs unter den gleichen\n // keys h\u00E4ngen blieben \u2192 React Error #31 beim Admin-Dropdown-Open.\n // setObjectAsync ersetzt common komplett (custom-Custom-Subscriptions\n // wie influxdb.0 bleiben via spread aus existing erhalten).\n // - Repair-Pfad f\u00FCr partial-formed Objects (v1.2.0-Migration-Bug, common\n // ohne top-level type/name/role) ist auch abgedeckt: existing-fields\n // werden von der full schema common komplett \u00FCberschrieben.\n // - `clients.<id>.manualUrl` bleibt extendObjectAsync \u2014 kein states-Feld,\n // daher kein i18n-Object-Risiko.\n const modeFullCommon: ioBroker.StateCommon = {\n // tName returns StringOrTranslated, which common.name accepts directly.\n name: tName(\"clientMode\"),\n // 'mixed' future-proofs against the upcoming js-controller\n // strict-type cast (see govee-smart v1.11.0 pattern).\n type: \"mixed\",\n role: \"state\",\n read: true,\n write: true,\n def: 0,\n states: mergedStates,\n };\n const ensureModeObject = async (): Promise<void> => {\n const existing = await this.adapter.getObjectAsync(`clients.${id}.mode`);\n if (existing) {\n const preservedName = existing.common?.name;\n existing.common = { ...existing.common, ...modeFullCommon };\n if (preservedName !== undefined) {\n existing.common.name = preservedName;\n }\n existing.type = \"state\";\n await this.adapter.setObjectAsync(`clients.${id}.mode`, existing);\n } else {\n await this.adapter.setObjectAsync(`clients.${id}.mode`, {\n type: \"state\",\n common: modeFullCommon,\n native: {},\n });\n }\n };\n await Promise.all([\n ensureModeObject(),\n this.adapter.extendObjectAsync(\n `clients.${id}.manualUrl`,\n {\n type: \"state\",\n common: {\n name: tName(\"clientManualUrl\"),\n type: \"string\",\n role: \"url\",\n read: true,\n write: true,\n def: \"\",\n },\n native: {},\n },\n { preserve: { common: [\"name\"] } },\n ),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.ip`, {\n type: \"state\",\n common: {\n name: tName(\"clientIp\"),\n type: \"string\",\n role: \"info.ip\",\n read: true,\n write: false,\n def: \"\",\n },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.remove`, {\n type: \"state\",\n common: {\n name: tName(\"clientRemove\"),\n type: \"boolean\",\n role: \"button\",\n read: false,\n write: true,\n def: false,\n },\n native: {},\n }),\n ]);\n }\n\n private async createObjects(record: ClientRecord): Promise<void> {\n await this.ensureObjects(record);\n const { id, mode, ip } = record;\n await Promise.all([\n this.adapter.setStateAsync(`clients.${id}.ip`, { val: ip ?? \"\", ack: true }),\n this.adapter.setStateAsync(`clients.${id}.mode`, { val: mode, ack: true }),\n this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: \"\", ack: true }),\n ]);\n }\n\n private async updateIpHostname(record: ClientRecord, ip: string | null, hostname: string | null): Promise<void> {\n if (ip && ip !== record.ip) {\n record.ip = ip;\n await this.adapter.setStateAsync(`clients.${record.id}.ip`, { val: ip, ack: true });\n // If no hostname known yet, common.name falls back to the IP \u2014 keep it current.\n if (!record.hostname) {\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: ip } });\n }\n }\n if (hostname && hostname !== record.hostname) {\n record.hostname = hostname;\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: hostname } });\n }\n }\n\n private async readState(subId: string): Promise<unknown> {\n // v1.20.0 (F10): Helper aus coerce.ts \u2014 vorher dupliziert mit\n // global-config.safeGetState (gleicher try/catch-+-null-Fallback,\n // nur Pfad-Prefix anders).\n const s = await safeGetState(this.adapter, `clients.${subId}`);\n return s?.val ?? null;\n }\n}\n\n/**\n * Check whether a full state ID matches a client control datapoint and extract id + kind.\n *\n * @param fullId The full state id from a state change event.\n * @param namespace The adapter namespace (e.g. `hassemu.0`).\n */\nexport function parseClientStateId(\n fullId: string,\n namespace: string,\n): { id: string; kind: \"mode\" | \"manualUrl\" | \"remove\" } | null {\n // v1.20.0 (F9): generischer parseAdapterStateId-Helper. Vorher hatte\n // client-registry seine eigene Prefix-+-Tail-Validierung dupliziert mit\n // global-config.parseGlobalStateId.\n const parts = parseAdapterStateId(fullId, namespace, CLIENTS_PREFIX, 2);\n if (!parts) {\n return null;\n }\n const [id, kind] = parts;\n // v1.9.0 (E5): empty id rejection (`clients..mode` would parse to id='').\n if (!id) {\n return null;\n }\n if (kind !== \"mode\" && kind !== \"manualUrl\" && kind !== \"remove\") {\n return null;\n }\n return { id, kind };\n}\n"],
4
+ "sourcesContent": ["/**\n * Client Registry \u2014 persistent multi-client store.\n *\n * Each client gets a channel `clients.<id>` with native.cookie / native.token\n * and states mode / manualUrl / ip / remove. Cookie is the primary identity\n * (auto-sent by browsers on page navigation), IP is only advisory.\n *\n * Registry state is dual-homed: in-memory maps for hot lookups, ioBroker\n * objects for persistence and user-visible config.\n */\n\nimport crypto from \"node:crypto\";\nimport {\n buildDropdownStates,\n coerceSafeUrl,\n coerceString,\n coerceUuid,\n evictOldest,\n isPlainObject,\n parseAdapterStateId,\n parseManualUrlWrite,\n parseModeWrite,\n safeGetState,\n} from \"./coerce\";\nimport { MODE_GLOBAL, MODE_MANUAL, NEW_CLIENT_BURST_CAP } from \"./constants\";\nimport { resolveLabel, tName } from \"./i18n\";\nimport { generateClientId } from \"./network\";\nimport type { AdapterInterface, ClientRecord, UrlStates } from \"./types\";\n\n/** Extended adapter interface for registry \u2014 needs object and state operations. */\nexport type RegistryAdapter = AdapterInterface &\n Pick<\n ioBroker.Adapter,\n | \"namespace\"\n | \"getForeignObjectsAsync\"\n | \"getStateAsync\"\n | \"getObjectAsync\"\n | \"setObjectNotExistsAsync\"\n | \"setObjectAsync\"\n | \"extendObjectAsync\"\n | \"setStateAsync\"\n | \"delObjectAsync\"\n >;\n\nconst CLIENTS_PREFIX = \"clients.\";\n\n/** Provides the default mode value for a freshly created client. */\nexport type NewClientModeProvider = () => string;\n\n/** Persistent multi-client store: cookie \u2192 channel, with in-memory lookup maps. */\nexport class ClientRegistry {\n private readonly adapter: RegistryAdapter;\n private readonly byCookie = new Map<string, ClientRecord>();\n private readonly byId = new Map<string, ClientRecord>();\n private readonly byToken = new Map<string, ClientRecord>();\n private readonly byRefreshToken = new Map<string, ClientRecord>();\n private currentUrlStates: UrlStates = {};\n private newClientModeProvider: NewClientModeProvider = () => MODE_GLOBAL;\n /**\n * In-flight client creations keyed by remote IP. Keeps parallel cookieless\n * requests from the same display (typical on first connect: HA clients fire\n * `GET /`, `GET /api/`, `POST /auth/login_flow` almost simultaneously) from\n * each creating a separate client record. The first request starts the\n * create; parallel requests await the same Promise and receive the same\n * client + cookie.\n */\n private readonly pendingByIp = new Map<string, Promise<ClientRecord>>();\n /**\n * Throttle for lastSeen-updates per client. Keyed by client id, value is the\n * last `Date.now()` we wrote `native.lastSeen` to ioBroker. Throttle window\n * is one hour \u2014 saves us extendObject roundtrips on every request.\n */\n private readonly lastSeenFlushedAt = new Map<string, number>();\n /**\n * v1.19.0 (G5): per-IP burst tracking f\u00FCr broken-cookie-Displays.\n * Wenn eine IP > 3 neue Clients in einer Stunde erzeugt, kommt ein\n * einmaliger warn-log mit Hinweis (cookie-Persistenz auf Display kaputt).\n */\n private readonly newClientBurst = new Map<string, { count: number; since: number; warnedAt: number }>();\n\n /** @param adapter Adapter instance used for object/state I/O. */\n constructor(adapter: RegistryAdapter) {\n this.adapter = adapter;\n }\n\n /**\n * Wires the default-mode provider used when a new client is registered.\n * Called from main.ts once registry, globalConfig and urlDiscovery exist.\n *\n * @param provider Function returning the desired default mode for a new client.\n */\n setNewClientModeProvider(provider: NewClientModeProvider): void {\n this.newClientModeProvider = provider;\n }\n\n /** Loads existing clients from ioBroker objects into memory. Call once on adapter start. */\n async restore(): Promise<void> {\n let channels: Record<string, ioBroker.ChannelObject> = {};\n try {\n channels = (await this.adapter.getForeignObjectsAsync(`${this.adapter.namespace}.clients.*`, \"channel\")) ?? {};\n } catch (err) {\n this.adapter.log.debug(`client-registry: restore failed: ${String(err)}`);\n return;\n }\n\n for (const [fullId, obj] of Object.entries(channels)) {\n const id = fullId.substring(`${this.adapter.namespace}.clients.`.length);\n if (!id || id.includes(\".\")) {\n continue;\n }\n // v1.28.3 (HE1): per-client try/catch \u2014 bei Promise.all auf 4\n // readState-Calls reicht ein einzelner Reject (z.B. corrupted state\n // bei migrating jsonl-store), und ALLE folgenden Clients werden\n // nicht restored. Pro-Client-Wrap entkoppelt: ein broken Client\n // kostet nur sich selbst.\n try {\n const native = isPlainObject(obj.native) ? obj.native : {};\n const cookie = coerceUuid(native.cookie);\n if (!cookie) {\n continue;\n }\n // v1.9.0 (D8): vier readState-Calls parallel statt sequenziell.\n // Mit 50 Clients waren das vorher 200 sequenzielle Round-Trips\n // bevor der WebServer up war; jetzt 50 parallele 4er-Gruppen.\n const [modeRaw, manualUrlRaw, ipRaw, hostnameRaw] = await Promise.all([\n this.readState(`${id}.mode`),\n this.readState(`${id}.manualUrl`),\n this.readState(`${id}.ip`),\n this.readState(`${id}.hostname`),\n ]);\n const mode = typeof modeRaw === \"string\" ? modeRaw : \"\";\n const manualUrl = coerceSafeUrl(manualUrlRaw);\n const ip = coerceString(ipRaw);\n const token = coerceUuid(native.token);\n const refreshToken = coerceUuid(native.refreshToken);\n\n // Legacy migration (<=1.1.1): hostname lived in its own state. If present,\n // move the value into common.name and drop the state.\n const legacyHostname = coerceString(hostnameRaw);\n let channelName = coerceString(obj.common?.name);\n if (legacyHostname) {\n this.adapter.log.debug(\n `restore: legacy hostname migration for client ${id} \u2014 '${legacyHostname}' moved to common.name`,\n );\n if (legacyHostname !== channelName) {\n await this.adapter.extendObjectAsync(`clients.${id}`, { common: { name: legacyHostname } });\n channelName = legacyHostname;\n }\n try {\n await this.adapter.delObjectAsync(`clients.${id}.hostname`);\n } catch {\n /* best effort \u2014 ignore */\n }\n }\n const hostname = channelName && channelName !== ip && channelName !== id ? channelName : null;\n\n const record: ClientRecord = { id, cookie, token, refreshToken, mode, manualUrl, ip, hostname };\n this.trackInMemory(record);\n // Legacy clients (v1.1.x) only had `visUrl` + `ip` + `remove` objects;\n // ensure the v1.2.0+ objects (`mode`, `manualUrl`) exist before any\n // state writes from migration land \u2014 otherwise js-controller logs\n // \"State has no existing object\" warnings.\n await this.ensureObjects(record);\n // Promote blank state-value to numeric 0 so the dropdown renders\n // the `0='---'` option as selected. v1.2.0 installs left the value\n // as `''` which doesn't match any common.states entry.\n const modeStateRaw = await this.readState(`${id}.mode`);\n if (modeStateRaw === \"\" || modeStateRaw === null || modeStateRaw === undefined) {\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n }\n } catch (err) {\n this.adapter.log.debug(`client-registry: skipping ${id} during restore \u2014 ${String(err)}`);\n }\n }\n this.adapter.log.debug(`client-registry: restored ${this.byId.size} client(s)`);\n }\n\n /**\n * Find the client for this cookie or create a new one.\n * Creates channel + states on first call and updates IP/hostname if changed.\n *\n * @param cookie Incoming cookie value (may be null/invalid).\n * @param ip Remote IP observed by the server.\n * @param hostname Optional hostname (from reverse DNS), stored for the admin UI.\n * @param userAgent Optional User-Agent header for NAT-collision-Schutz im Pending-Lock.\n */\n async identifyOrCreate(\n cookie: string | null,\n ip: string | null,\n hostname: string | null,\n userAgent: string | null = null,\n ): Promise<ClientRecord> {\n const validCookie = coerceUuid(cookie);\n if (validCookie) {\n const existing = this.byCookie.get(validCookie);\n if (existing) {\n await this.updateIpHostname(existing, ip, hostname);\n this.touchLastSeen(existing);\n return existing;\n }\n }\n // No valid cookie: before spinning up a new client, check whether this\n // IP already has a create in flight. If so, await that Promise \u2014 the\n // parallel request of the same display's initial burst will get the\n // same cookie + client, no more duplicate \"New client\" log entries.\n //\n // v1.17.0 (C8): Bucket-Key kombiniert IP + User-Agent-Hash, sodass\n // zwei verschiedene Displays hinter derselben NAT-IP NICHT in denselben\n // Pending-Lock fallen (vorher: gleicher Cookie/Token/Mode \u2192 Cookie-\n // Klau-Vektor). UA-Hash truncated auf 12 Hex-Chars um Memory-Footprint\n // klein zu halten. Bei UA=null f\u00E4llt der Bucket auf reines IP zur\u00FCck.\n if (ip) {\n const bucketKey = userAgent\n ? `${ip}|${crypto.createHash(\"sha256\").update(userAgent).digest(\"hex\").substring(0, 12)}`\n : ip;\n const pending = this.pendingByIp.get(bucketKey);\n if (pending) {\n // v1.21.0 (D3): pending-promise kann rejecten \u2014 z.B. wenn\n // createClient async failed (broker-disconnect, object-create-\n // error). Wir k\u00F6nnen nicht recover'n (der erste Caller hat eh\n // schon gefailt), aber wir wollen den Fehler diagnostizierbar\n // machen. catch+rethrow sorgt f\u00FCr ein einzelnes log statt\n // unhandled-rejection im fastify-error-handler.\n return pending.catch(err => {\n this.adapter.log.debug(`client-registry: pending createClient for ${bucketKey} rejected: ${String(err)}`);\n throw err;\n });\n }\n const promise = this.createClient(ip, hostname);\n this.pendingByIp.set(bucketKey, promise);\n try {\n return await promise;\n } catch (err) {\n // Tech-Diagnose mit Stack-Detail \u2014 bleibt debug (Maintainer-only).\n this.adapter.log.debug(\n `client-registry: createClient failed for IP ${ip}: ${err instanceof Error ? err.message : String(err)}`,\n );\n throw err;\n } finally {\n this.pendingByIp.delete(bucketKey);\n }\n }\n return this.createClient(ip, hostname);\n }\n\n /**\n * Lookup by short client id (channel segment).\n *\n * @param id Client id.\n */\n getById(id: string): ClientRecord | null {\n return this.byId.get(id) ?? null;\n }\n\n /**\n * Lookup by cookie value. Invalid UUIDs return null.\n *\n * @param cookie Raw cookie string.\n */\n getByCookie(cookie: string): ClientRecord | null {\n const v = coerceUuid(cookie);\n return v ? (this.byCookie.get(v) ?? null) : null;\n }\n\n /**\n * Lookup by access token issued during the auth flow.\n *\n * @param token Bearer token.\n */\n getByToken(token: string): ClientRecord | null {\n return this.byToken.get(token) ?? null;\n }\n\n /**\n * Lookup by refresh token issued during the auth flow.\n *\n * @param refreshToken Refresh token value.\n */\n getByRefreshToken(refreshToken: string): ClientRecord | null {\n return this.byRefreshToken.get(refreshToken) ?? null;\n }\n\n /** Returns a snapshot array of all registered clients. */\n listAll(): ClientRecord[] {\n return [...this.byId.values()];\n }\n\n /**\n * Updates in-memory token and persists to channel.native. Old token is freed.\n *\n * @param id Client id.\n * @param token New bearer token, or null to clear.\n */\n async setToken(id: string, token: string | null): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n if (record.token) {\n this.byToken.delete(record.token);\n }\n record.token = token;\n if (token) {\n this.byToken.set(token, record);\n }\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { token } });\n }\n\n /**\n * Updates in-memory refresh token and persists to channel.native. Old refresh\n * token is freed. Stored plain-text in `clients.<id>.native.refreshToken` \u2014\n * same exposure profile as the access token (see {@link ClientRecord.refreshToken}).\n *\n * @param id Client id.\n * @param refreshToken New refresh token, or null to clear.\n */\n async setRefreshToken(id: string, refreshToken: string | null): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n if (record.refreshToken) {\n this.byRefreshToken.delete(record.refreshToken);\n }\n record.refreshToken = refreshToken;\n if (refreshToken) {\n this.byRefreshToken.set(refreshToken, record);\n }\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { refreshToken } });\n }\n\n /**\n * Accept an external mode write on `clients.<id>.mode`.\n *\n * Allowed values: `'global'`, `'manual'`, or any URL that passes\n * {@link coerceSafeUrl}. Empty string clears the choice \u2192 setup page.\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleModeWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n // v1.23.0 (F2): zentralisierte Validierung via parseModeWrite. Vorher\n // hatten client-registry und global-config ~80% identische Logik\n // (no-choice, non-string, sentinel, URL-coerce) dupliziert.\n const result = parseModeWrite(rawValue, [MODE_GLOBAL, MODE_MANUAL]);\n switch (result.kind) {\n case \"no-choice\":\n record.mode = \"\";\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: 0, ack: true });\n this.adapter.log.debug(`Client ${id}: mode \u2192 cleared (no-choice)`);\n return;\n case \"rejected-non-string\":\n // v1.18.0 (G7): debug statt warn \u2014 nicht-string mode-Schreibungen\n // sind UI-Echo, kein Server-Concern.\n this.adapter.log.debug(`client-registry: rejected non-string mode for ${id}`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n return;\n case \"sentinel\":\n if (result.value === MODE_MANUAL && !record.manualUrl) {\n this.adapter.log.warn(\n `Client ${id}: mode set to \"manual\" but manualUrl is empty \u2014 fill clients.${id}.manualUrl to redirect`,\n );\n }\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n this.adapter.log.debug(`Client ${id}: mode \u2192 '${result.value}' (sentinel)`);\n return;\n case \"rejected-unsafe-url\":\n this.adapter.log.warn(`Client ${id}: rejected unsafe mode value \"${result.raw}\"`);\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode, ack: true });\n return;\n case \"url\":\n record.mode = result.value;\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: result.value, ack: true });\n this.adapter.log.debug(`Client ${id}: mode \u2192 ${result.value} (direct URL)`);\n return;\n // 'rejected-disallowed-sentinel' kommt hier nicht vor weil beide\n // Sentinels (global/manual) erlaubt sind. Defensive: revert.\n default:\n await this.adapter.setStateAsync(`clients.${id}.mode`, { val: record.mode || 0, ack: true });\n }\n }\n\n /**\n * Accept an external manualUrl write on `clients.<id>.manualUrl`.\n * Free-text \u2014 must pass {@link coerceSafeUrl} or be empty (clears).\n *\n * @param id Client id.\n * @param rawValue Value written to the state.\n */\n async handleManualUrlWrite(id: string, rawValue: unknown): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n const result = parseManualUrlWrite(rawValue);\n if (!result.ok) {\n this.adapter.log.warn(`Client ${id}: rejected unsafe manualUrl value`);\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: record.manualUrl ?? \"\", ack: true });\n return;\n }\n record.manualUrl = result.safe;\n await this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: result.safe ?? \"\", ack: true });\n this.adapter.log.debug(`Client ${id}: manualUrl \u2192 ${result.safe ?? \"cleared\"}`);\n if (record.mode === MODE_MANUAL && !result.safe) {\n this.adapter.log.warn(`Client ${id}: manualUrl cleared while mode is \"manual\" \u2014 display will see the setup page`);\n }\n }\n\n /**\n * Set every client's `mode` to the same value. Used by the master switch\n * (`global.enabled`) to bulk-sync all displays \u2014 `'global'` when on,\n * `'0'` (no-choice \u2192 landing page) when off.\n *\n * Skips clients whose mode already matches (no spurious state writes).\n *\n * @param value New mode value (sentinel or URL).\n */\n async bulkSetMode(value: string): Promise<void> {\n // v1.8.1 (D7): parallele setStateAsync statt sequenziell. Mit 50 Displays\n // war das vorher 50 Broker-Round-Trips. setStateAsync ist Broker-internal,\n // Parallelism ist safe.\n const writes: Array<Promise<unknown>> = [];\n let changed = 0;\n for (const record of this.byId.values()) {\n if (record.mode === value) {\n continue;\n }\n record.mode = value;\n writes.push(this.adapter.setStateAsync(`clients.${record.id}.mode`, { val: value, ack: true }));\n changed++;\n }\n if (writes.length > 0) {\n await Promise.all(writes);\n }\n if (changed > 0) {\n this.adapter.log.debug(`bulkSetMode applied to ${changed} client(s)`);\n }\n }\n\n /**\n * Removes the client entirely \u2014 channel + states deleted, next visit creates a new entry.\n *\n * @param id Client id to forget.\n */\n async remove(id: string): Promise<void> {\n const record = this.byId.get(id);\n if (!record) {\n return;\n }\n this.byId.delete(id);\n this.byCookie.delete(record.cookie);\n if (record.token) {\n this.byToken.delete(record.token);\n }\n if (record.refreshToken) {\n this.byRefreshToken.delete(record.refreshToken);\n }\n // v1.8.1 (D2): lastSeenFlushedAt war fr\u00FCher nicht aufger\u00E4umt \u2014 bei\n // ID-Reuse (16M-Space, m\u00F6glich nach 100+ Clients \u00FCber Jahre) h\u00E4tte\n // die alte Throttle-Entry den ersten lastSeen-Write des neuen Clients\n // inhibiert. Plus minimal Memory-Leak.\n this.lastSeenFlushedAt.delete(id);\n try {\n await this.adapter.delObjectAsync(`clients.${id}`, { recursive: true });\n } catch (err) {\n // Stack-trace level \u2014 Maintainer-Diagnose, EN bleibt.\n this.adapter.log.debug(`client-registry: delObject failed for ${id}: ${String(err)}`);\n }\n this.adapter.log.info(`Client forgotten: ${id}`);\n }\n\n /**\n * Updates the mode dropdown states (`common.states`) on every client's mode datapoint.\n * Adds the `'global'` and `'manual'` sentinels on top of the discovered URLs.\n *\n * @param states Discovered URL \u2192 label map.\n */\n async syncUrlDropdown(states: UrlStates): Promise<void> {\n this.currentUrlStates = states;\n const merged = this.buildModeStates();\n // v1.27.2: extendObjectAsync mergt `common.states` tief \u2014 alte\n // URL-Schl\u00FCssel die nicht mehr discovered werden, blieben sonst im\n // Dropdown stehen (sichtbar nach v1.26\u2192v1.27 URL-Format-Wechsel:\n // alte `vis-2.0/main/index.html`-Keys neben neuen `vis-2/index.html?main`).\n // Object lesen, common.states komplett ersetzen, dann setObjectAsync.\n // v1.30.0 (R4): pro-Client get+set parallel statt sequentiell. Analog\n // gcStaleClients in main.ts (v1.28.3 M5). Sp\u00FCrbar bei Display-Farmen\n // mit 30+ Clients \u2014 vorher 2\u00D7N Broker-Round-Trips sequenziell.\n await Promise.all(\n Array.from(this.byId.keys()).map(async id => {\n const stateId = `clients.${id}.mode`;\n const existing = await this.adapter.getObjectAsync(stateId);\n if (!existing) {\n return;\n }\n existing.common.states = merged;\n await this.adapter.setObjectAsync(stateId, existing);\n }),\n );\n }\n\n // --- internal ---\n\n private trackInMemory(record: ClientRecord): void {\n this.byId.set(record.id, record);\n this.byCookie.set(record.cookie, record);\n if (record.token) {\n this.byToken.set(record.token, record);\n }\n if (record.refreshToken) {\n this.byRefreshToken.set(record.refreshToken, record);\n }\n }\n\n private async createClient(ip: string | null, hostname: string | null): Promise<ClientRecord> {\n let id = generateClientId();\n while (this.byId.has(id)) {\n id = generateClientId();\n }\n const cookie = crypto.randomUUID();\n const mode = this.newClientModeProvider();\n const record: ClientRecord = {\n id,\n cookie,\n token: null,\n refreshToken: null,\n mode,\n manualUrl: null,\n ip,\n hostname,\n };\n this.trackInMemory(record);\n await this.createObjects(record);\n this.touchLastSeen(record);\n this.adapter.log.info(ip ? `New client connected: ${id} (${hostname ?? ip})` : `New client connected: ${id}`);\n // v1.19.0 (G5): IP-Burst-Detection f\u00FCr broken-cookie-Displays. Wenn\n // dieselbe IP > 3 neue Clients in einer Stunde erzeugt, ist der Cookie-\n // Mechanismus auf dem Display kaputt (aggressive Privacy, refresh-bug).\n // Einmaliger warn-Hinweis pro IP pro Stunde \u2014 danach bleibt der info-\n // log normal, aber der Operator hat wenigstens einen Anker zur Diagnose.\n if (ip) {\n this.recordNewClientIp(ip);\n }\n return record;\n }\n\n /**\n * v1.19.0 (G5): tracking-only \u2014 wenn eine IP > 3 neue Clients pro Stunde\n * erzeugt, einmaliger warn-log mit Diagnose-Hinweis. Danach 1h cooldown\n * pro IP. Map-Cap 200 (FIFO).\n *\n * @param ip Remote IP that just got a new ClientRecord assigned.\n */\n private recordNewClientIp(ip: string): void {\n const now = Date.now();\n const HOUR = 60 * 60 * 1000;\n const entry = this.newClientBurst.get(ip) ?? { count: 0, since: now, warnedAt: 0 };\n if (now - entry.since > HOUR) {\n // Window expired \u2014 reset.\n entry.count = 0;\n entry.since = now;\n }\n entry.count += 1;\n if (entry.count > 3 && now - entry.warnedAt > HOUR) {\n this.adapter.log.warn(\n `IP ${ip} created ${entry.count} clients within an hour \u2014 display likely is not persisting cookies (privacy mode? refresh bug?)`,\n );\n entry.warnedAt = now;\n }\n this.newClientBurst.set(ip, entry);\n // v1.32.0: Soft-Cap via shared `evictOldest` (vorher inline single-shot).\n evictOldest(this.newClientBurst, NEW_CLIENT_BURST_CAP);\n }\n\n /**\n * Updates `native.lastSeen` on the channel, throttled to once per hour per\n * client. Used for the stale-client-GC: clients without token + lastSeen\n * older than 30 days get auto-removed on adapter start.\n *\n * Fire-and-forget \u2014 failures only debug-logged.\n *\n * @param record Client whose lastSeen-timestamp should be refreshed.\n */\n private touchLastSeen(record: ClientRecord): void {\n const now = Date.now();\n const last = this.lastSeenFlushedAt.get(record.id) ?? 0;\n if (now - last < 60 * 60 * 1000) {\n return; // throttle: 1\u00D7 per hour\n }\n this.lastSeenFlushedAt.set(record.id, now);\n this.adapter\n .extendObjectAsync(`clients.${record.id}`, { native: { lastSeen: now } })\n .catch(err => this.adapter.log.debug(`touchLastSeen failed for ${record.id}: ${String(err)}`));\n }\n\n /**\n * v1.19.0 (F11): zentraler lastSeen-Seed-Pfad. Vorher hatte main.ts\n * gcStaleClients seinen eigenen extendObjectAsync-Call mit identischem\n * native-Format \u2014 DRY-Violation und gef\u00E4hrlich wenn das Format mal \u00E4ndert.\n * Jetzt nutzen beide Pfade diese Methode. Throttle-Map wird auch upgedated,\n * damit der n\u00E4chste touchLastSeen den seed nicht direkt \u00FCberschreibt.\n *\n * @param id Client id (short segment, ohne `clients.`-Prefix).\n * @param now Optionaler Timestamp f\u00FCr tests; default Date.now().\n */\n async seedLastSeen(id: string, now: number = Date.now()): Promise<void> {\n this.lastSeenFlushedAt.set(id, now);\n try {\n await this.adapter.extendObjectAsync(`clients.${id}`, { native: { lastSeen: now } });\n } catch (err) {\n this.adapter.log.debug(`seedLastSeen failed for ${id}: ${String(err)}`);\n }\n }\n\n /**\n * Builds the dropdown-states map for `clients.<id>.mode`. Includes the\n * `0='---'` no-choice fallback (analogous to the govee-smart pattern), the\n * `'global'` + `'manual'` sentinels, and all currently discovered URLs.\n */\n private buildModeStates(): UrlStates {\n // v1.20.0 (F4): Helper aus coerce.ts. Vorher dupliziert mit global-config\n // (bis auf den zus\u00E4tzlichen `global`-Sentinel hier, weil clients per\n // `mode='global'` an global delegieren k\u00F6nnen \u2014 global selbst nicht).\n // v1.28.4: Sentinel-Labels als plain-string in adapter.systemLanguage \u2014\n // Admin rendert common.states-VALUES direkt als React-child und crasht\n // bei Translation-Objects mit React Error #31. v1.28.0 hatte einen\n // helper hier via `as unknown as string`-Cast eingeschleust, der den\n // Type-Check still lie\u00DF aber den Admin-Dropdown beim \u00D6ffnen zerlegt\n // hat. Memory `reference_common_states_plain_string_only`.\n return buildDropdownStates(\n {\n [MODE_GLOBAL]: resolveLabel(\"globalUrl\"),\n [MODE_MANUAL]: resolveLabel(\"manualUrl\"),\n },\n this.currentUrlStates,\n );\n }\n\n /**\n * Idempotently creates all per-client objects (channel + states). Safe to\n * call repeatedly \u2014 uses `setObjectNotExistsAsync` everywhere. Called from\n * both `restore()` (so legacy v1.1.x clients gain the new mode/manualUrl\n * objects before migration writes states) and `createClient()`.\n *\n * @param record Client to create or ensure objects for.\n */\n private async ensureObjects(record: ClientRecord): Promise<void> {\n const { id, cookie, ip, hostname } = record;\n const mergedStates = this.buildModeStates();\n\n // Channel: setObjectNotExistsAsync \u2014 common.name is updated dynamically\n // by updateIpHostname() when reverse-DNS resolves; we must not clobber it.\n await this.adapter.setObjectNotExistsAsync(`clients.${id}`, {\n type: \"channel\",\n common: { name: hostname ?? ip ?? id },\n native: { cookie, token: null },\n });\n\n // States:\n // - `clients.<id>.mode` uses get+setObjectAsync (analog `global-config.ts`\n // v1.27.2-Fix): extendObjectAsync deep-merges common.states, weshalb\n // alte i18n-Object-Keys aus pre-v1.28.4-Installs unter den gleichen\n // keys h\u00E4ngen blieben \u2192 React Error #31 beim Admin-Dropdown-Open.\n // setObjectAsync ersetzt common komplett (custom-Custom-Subscriptions\n // wie influxdb.0 bleiben via spread aus existing erhalten).\n // - Repair-Pfad f\u00FCr partial-formed Objects (v1.2.0-Migration-Bug, common\n // ohne top-level type/name/role) ist auch abgedeckt: existing-fields\n // werden von der full schema common komplett \u00FCberschrieben.\n // - `clients.<id>.manualUrl` bleibt extendObjectAsync \u2014 kein states-Feld,\n // daher kein i18n-Object-Risiko.\n const modeFullCommon: ioBroker.StateCommon = {\n // tName returns StringOrTranslated, which common.name accepts directly.\n name: tName(\"clientMode\"),\n // 'mixed' future-proofs against the upcoming js-controller\n // strict-type cast (see govee-smart v1.11.0 pattern).\n type: \"mixed\",\n role: \"state\",\n read: true,\n write: true,\n def: 0,\n states: mergedStates,\n };\n const ensureModeObject = async (): Promise<void> => {\n const existing = await this.adapter.getObjectAsync(`clients.${id}.mode`);\n if (existing) {\n const preservedName = existing.common?.name;\n existing.common = { ...existing.common, ...modeFullCommon };\n if (preservedName !== undefined) {\n existing.common.name = preservedName;\n }\n existing.type = \"state\";\n await this.adapter.setObjectAsync(`clients.${id}.mode`, existing);\n } else {\n await this.adapter.setObjectAsync(`clients.${id}.mode`, {\n type: \"state\",\n common: modeFullCommon,\n native: {},\n });\n }\n };\n await Promise.all([\n ensureModeObject(),\n this.adapter.extendObjectAsync(\n `clients.${id}.manualUrl`,\n {\n type: \"state\",\n common: {\n name: tName(\"clientManualUrl\"),\n type: \"string\",\n role: \"url\",\n read: true,\n write: true,\n def: \"\",\n },\n native: {},\n },\n { preserve: { common: [\"name\"] } },\n ),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.ip`, {\n type: \"state\",\n common: {\n name: tName(\"clientIp\"),\n type: \"string\",\n role: \"info.ip\",\n read: true,\n write: false,\n def: \"\",\n },\n native: {},\n }),\n this.adapter.setObjectNotExistsAsync(`clients.${id}.remove`, {\n type: \"state\",\n common: {\n name: tName(\"clientRemove\"),\n type: \"boolean\",\n role: \"button\",\n read: false,\n write: true,\n def: false,\n },\n native: {},\n }),\n ]);\n }\n\n private async createObjects(record: ClientRecord): Promise<void> {\n await this.ensureObjects(record);\n const { id, mode, ip } = record;\n await Promise.all([\n this.adapter.setStateAsync(`clients.${id}.ip`, { val: ip ?? \"\", ack: true }),\n this.adapter.setStateAsync(`clients.${id}.mode`, { val: mode, ack: true }),\n this.adapter.setStateAsync(`clients.${id}.manualUrl`, { val: \"\", ack: true }),\n ]);\n }\n\n private async updateIpHostname(record: ClientRecord, ip: string | null, hostname: string | null): Promise<void> {\n if (ip && ip !== record.ip) {\n record.ip = ip;\n await this.adapter.setStateAsync(`clients.${record.id}.ip`, { val: ip, ack: true });\n // If no hostname known yet, common.name falls back to the IP \u2014 keep it current.\n if (!record.hostname) {\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: ip } });\n }\n }\n if (hostname && hostname !== record.hostname) {\n record.hostname = hostname;\n await this.adapter.extendObjectAsync(`clients.${record.id}`, { common: { name: hostname } });\n }\n }\n\n private async readState(subId: string): Promise<unknown> {\n // v1.20.0 (F10): Helper aus coerce.ts \u2014 vorher dupliziert mit\n // global-config.safeGetState (gleicher try/catch-+-null-Fallback,\n // nur Pfad-Prefix anders).\n const s = await safeGetState(this.adapter, `clients.${subId}`);\n return s?.val ?? null;\n }\n}\n\n/**\n * Check whether a full state ID matches a client control datapoint and extract id + kind.\n *\n * @param fullId The full state id from a state change event.\n * @param namespace The adapter namespace (e.g. `hassemu.0`).\n */\nexport function parseClientStateId(\n fullId: string,\n namespace: string,\n): { id: string; kind: \"mode\" | \"manualUrl\" | \"remove\" } | null {\n // v1.20.0 (F9): generischer parseAdapterStateId-Helper. Vorher hatte\n // client-registry seine eigene Prefix-+-Tail-Validierung dupliziert mit\n // global-config.parseGlobalStateId.\n const parts = parseAdapterStateId(fullId, namespace, CLIENTS_PREFIX, 2);\n if (!parts) {\n return null;\n }\n const [id, kind] = parts;\n // v1.9.0 (E5): empty id rejection (`clients..mode` would parse to id='').\n if (!id) {\n return null;\n }\n if (kind !== \"mode\" && kind !== \"manualUrl\" && kind !== \"remove\") {\n return null;\n }\n return { id, kind };\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": ["crypto", "_a"]
7
7
  }
@@ -153,31 +153,22 @@ function parseModeWrite(rawValue, allowedSentinels) {
153
153
  return { kind: "url", value: safe };
154
154
  }
155
155
  function coerceSafeUrl(value) {
156
- return coerceSafeUrlReason(value).safe;
157
- }
158
- function coerceSafeUrlReason(value) {
159
- if (typeof value !== "string") {
160
- return { safe: null, reason: "not-a-string" };
161
- }
162
- if (value.length === 0) {
163
- return { safe: null, reason: "empty" };
164
- }
165
- if (value.length > 2048) {
166
- return { safe: null, reason: "too-long" };
156
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) {
157
+ return null;
167
158
  }
168
159
  let url;
169
160
  try {
170
161
  url = new URL(value);
171
162
  } catch {
172
- return { safe: null, reason: "unparseable" };
163
+ return null;
173
164
  }
174
165
  if (url.protocol !== "http:" && url.protocol !== "https:") {
175
- return { safe: null, reason: `bad-scheme:${url.protocol}` };
166
+ return null;
176
167
  }
177
168
  if (url.username.length > 0 || url.password.length > 0) {
178
- return { safe: null, reason: "credentials-in-url" };
169
+ return null;
179
170
  }
180
- return { safe: value, reason: null };
171
+ return value;
181
172
  }
182
173
  async function safeGetState(adapter, id) {
183
174
  var _a;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/coerce.ts"],
4
- "sourcesContent": ["/**\n * Boundary coercion helpers for external input.\n *\n * hassemu receives data from HTTP clients, cookies, foreign adapter objects\n * and user writes on states. TypeScript types only guarantee compile-time\n * shape \u2014 these helpers guard runtime reality.\n */\n\nimport crypto from \"node:crypto\";\n\nimport { MODE_GLOBAL, MODE_MANUAL } from \"./constants\";\n\n/**\n * IndieAuth-style redirect_uri validation + HA Companion App whitelist.\n *\n * Three accept paths (matches home-assistant/core/homeassistant/components/auth/indieauth.py:30-55):\n * 1. Same scheme + netloc as client_id (default IndieAuth).\n * 2. Hardcoded whitelist for HA Companion iOS/Android apps \u2014 needed because\n * they use custom URI scheme `homeassistant://` which can never pass rule (1).\n * 3. IndieAuth 4.2.2 \u2014 fetch client_id URL, parse `<link rel=\"redirect_uri\">`\n * tags. NOT implemented here \u2014 LAN emulation can't rely on internet fetch.\n *\n * Always rejects dangerous schemes (javascript:, data:, vbscript:, file:)\n * regardless of any other rule. Defense-in-depth \u2014 defense against an\n * accidental whitelist drift.\n *\n * @param clientId Untrusted `client_id` from OAuth2 query (an absolute URL).\n * @param redirectUri Untrusted `redirect_uri` from OAuth2 query.\n */\nexport function isValidRedirectUri(clientId: string, redirectUri: string): boolean {\n if (typeof clientId !== \"string\" || typeof redirectUri !== \"string\") {\n return false;\n }\n if (clientId.length === 0 || redirectUri.length === 0) {\n return false;\n }\n if (redirectUri.length > 2048 || clientId.length > 2048) {\n return false;\n }\n // Dangerous schemes \u2014 never accept regardless of any other rule.\n const lower = redirectUri.toLowerCase();\n const forbidden = [\"javascript:\", \"data:\", \"vbscript:\", \"file:\"];\n for (const scheme of forbidden) {\n if (lower.startsWith(scheme)) {\n return false;\n }\n }\n\n // (2) HA Companion App whitelist \u2014 must be checked before (1) because\n // these apps use `homeassistant://` which has no http(s) netloc to match.\n // Source: home-assistant/core indieauth.py:39-50.\n if (clientId === \"https://home-assistant.io/iOS\" && redirectUri === \"homeassistant://auth-callback\") {\n return true;\n }\n if (\n clientId === \"https://home-assistant.io/android\" &&\n (redirectUri === \"homeassistant://auth-callback\" ||\n redirectUri === \"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android\" ||\n redirectUri === \"https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android\")\n ) {\n return true;\n }\n\n // (1) Default IndieAuth rule \u2014 same scheme + netloc.\n try {\n const cid = new URL(clientId);\n const ru = new URL(redirectUri);\n return cid.protocol === ru.protocol && cid.host === ru.host;\n } catch {\n return false;\n }\n}\n\n/**\n * v1.22.0 (F5): vormals in webserver.ts. Constant-time string comparison\n * for credential checks. Length-leak-resistant via SHA-256-Digest-Vergleich:\n * beide Inputs werden auf eine fixe 32-Byte-L\u00E4nge gehasht, dann timing-safe\n * verglichen.\n *\n * v1.16.0 (C6): vorher `if (ab.length !== bb.length) return false` VOR\n * timingSafeEqual \u2014 die L\u00E4ngen-Differenz war \u00FCber Response-Timing\n * erschn\u00FCffelbar.\n *\n * @param a First string to compare.\n * @param b Second string to compare.\n */\nexport function safeStringEqual(a: string, b: string): boolean {\n const ah = crypto.createHash(\"sha256\").update(a, \"utf8\").digest();\n const bh = crypto.createHash(\"sha256\").update(b, \"utf8\").digest();\n return crypto.timingSafeEqual(ah, bh);\n}\n\n/**\n * \u201ENo-choice\"-Marker: User hat den Default-Eintrag `0='---'` (oder eine seiner\n * Repr\u00E4sentationen) gew\u00E4hlt. Behandelt sowohl die numerische `0` (Admin-UI\n * mit `type: mixed` Dropdowns), die String-Variante `'0'` und den leeren String.\n *\n * Wird in beiden Mode-Handlers (client-registry, global-config) gleich behandelt\n * \u2014 vor v1.8.0 war die Logik 4\u00D7 dupliziert. Jeder andere Wert ist ein \u201Eechter\"\n * User-Input und muss validiert werden.\n *\n * @param value Untrusted input vom Mode-State (numeric 0 / string '0' / '' / URL / sentinel).\n */\nexport function isNoChoice(value: unknown): boolean {\n return value === 0 || value === \"0\" || value === \"\";\n}\n\n/**\n * Coerce to a finite number, or null. Rejects NaN, Infinity, non-numeric strings.\n *\n * @param value Untrusted input.\n */\n// v1.9.0 (E8): nur dezimale Zahlen \u2014 `Number()` w\u00FCrde sonst auch\n// '0x1FBB' (HEX) und '8.123e3' (Exponential) akzeptieren. In url-discovery\n// f\u00FCr Port-Felder w\u00E4re HEX-Acceptance Schaden-Vektor.\nconst DECIMAL_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/;\n\n/**\n * Coerce to a finite number, or null. Rejects NaN, Infinity, non-decimal\n * strings (HEX, exponential, scientific notation).\n *\n * @param value Untrusted input.\n */\nexport function coerceFiniteNumber(value: unknown): number | null {\n if (typeof value === \"number\") {\n return Number.isFinite(value) ? value : null;\n }\n if (typeof value === \"string\" && DECIMAL_NUMBER_RE.test(value)) {\n const n = Number(value);\n return Number.isFinite(n) ? n : null;\n }\n return null;\n}\n\n/**\n * Coerce to a non-empty string, or null.\n *\n * @param value Untrusted input.\n */\nexport function coerceString(value: unknown): string | null {\n if (typeof value === \"string\" && value.length > 0) {\n return value;\n }\n return null;\n}\n\n/**\n * Coerce to a boolean. Only accepts actual `true` / `false`.\n *\n * @param value Untrusted input.\n */\nexport function coerceBoolean(value: unknown): boolean | null {\n if (typeof value === \"boolean\") {\n return value;\n }\n return null;\n}\n\n/**\n * Guard for plain objects (not arrays, not null).\n *\n * @param value Untrusted input.\n */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/**\n * Coerce to a UUID string, or null. Accepts any UUID variant.\n *\n * @param value Untrusted input.\n */\nexport function coerceUuid(value: unknown): string | null {\n if (typeof value !== \"string\") {\n return null;\n }\n return UUID_REGEX.test(value) ? value.toLowerCase() : null;\n}\n\n/** Result of {@link parseManualUrlWrite}. */\nexport type ManualUrlWriteResult = { ok: true; safe: string | null } | { ok: false };\n\n/**\n * Validates a write to a `manualUrl` state. Empty / null / undefined \u2192 clear\n * (`safe: null`). Otherwise must pass {@link coerceSafeUrl}; if not \u2192 `ok: false`\n * so the caller can reject + revert. Centralises the validation that both\n * `ClientRegistry.handleManualUrlWrite` and `GlobalConfig.handleManualUrlWrite`\n * share \u2014 caller still owns logging + setState because the prefixes/state-IDs\n * differ.\n *\n * @param rawValue Value written to the state.\n */\nexport function parseManualUrlWrite(rawValue: unknown): ManualUrlWriteResult {\n const empty = rawValue === \"\" || rawValue === null || rawValue === undefined;\n if (empty) {\n return { ok: true, safe: null };\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n return { ok: false };\n }\n return { ok: true, safe };\n}\n\n/** Result of {@link parseModeWrite}. */\nexport type ModeWriteResult =\n | { kind: \"no-choice\" }\n | { kind: \"sentinel\"; value: string }\n | { kind: \"url\"; value: string }\n | { kind: \"rejected-non-string\" }\n | { kind: \"rejected-disallowed-sentinel\"; value: string }\n | { kind: \"rejected-unsafe-url\"; raw: string };\n\n/**\n * v1.23.0 (F2): zentralisierte Validierung f\u00FCr Mode-Writes. Vorher hatten\n * `ClientRegistry.handleModeWrite` und `GlobalConfig.handleModeWrite` ~80%\n * der Logik dupliziert (no-choice, non-string-reject, sentinel-check, URL-\n * coerce). Beide nutzen jetzt diesen Helper und steuern nur ihre eigenen\n * State-IDs / Logging-Prefixes / erlaubte Sentinels.\n *\n * `allowedSentinels` ist die Liste der zul\u00E4ssigen non-URL Mode-Werte \u2014\n * client-registry erlaubt z.B. `[MODE_GLOBAL, MODE_MANUAL]`, global-config\n * nur `[MODE_MANUAL]` (MODE_GLOBAL w\u00E4re self-referential).\n *\n * @param rawValue Wert vom State-Write.\n * @param allowedSentinels Erlaubte Non-URL-Sentinels.\n */\nexport function parseModeWrite(rawValue: unknown, allowedSentinels: readonly string[]): ModeWriteResult {\n if (isNoChoice(rawValue)) {\n return { kind: \"no-choice\" };\n }\n if (typeof rawValue !== \"string\") {\n return { kind: \"rejected-non-string\" };\n }\n // String-Sentinels haben Vorrang vor URL-Coerce.\n if (allowedSentinels.includes(rawValue)) {\n return { kind: \"sentinel\", value: rawValue };\n }\n // Disallowed-Sentinel-Detection: wenn der Caller MODE_GLOBAL/MODE_MANUAL\n // als known-strings hat, aber sie nicht in allowedSentinels sind, melden\n // wir das explizit (f\u00FCr Self-Referential-Check in global-config).\n if (rawValue === MODE_GLOBAL || rawValue === MODE_MANUAL) {\n return { kind: \"rejected-disallowed-sentinel\", value: rawValue };\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n return { kind: \"rejected-unsafe-url\", raw: rawValue };\n }\n return { kind: \"url\", value: safe };\n}\n\n/**\n * Coerce to a safe redirect URL, or null.\n *\n * Requirements:\n * - http:// or https:// scheme (no javascript:, data:, file:, etc.)\n * - Parseable by URL()\n * - No embedded credentials (user:pass@host)\n * - Max 2048 chars\n *\n * @param value Untrusted input.\n */\nexport function coerceSafeUrl(value: unknown): string | null {\n return coerceSafeUrlReason(value).safe;\n}\n\n/**\n * Internal worker behind {@link coerceSafeUrl}: returns the safe URL plus a\n * short machine-readable reason on rejection. The reason has no production\n * consumer (callers only need the safe URL / null via `coerceSafeUrl`), so this\n * stays module-private; the branches are exercised through `coerceSafeUrl`.\n *\n * @param value Untrusted input.\n */\nfunction coerceSafeUrlReason(value: unknown): { safe: string | null; reason: string | null } {\n if (typeof value !== \"string\") {\n return { safe: null, reason: \"not-a-string\" };\n }\n if (value.length === 0) {\n return { safe: null, reason: \"empty\" };\n }\n if (value.length > 2048) {\n return { safe: null, reason: \"too-long\" };\n }\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return { safe: null, reason: \"unparseable\" };\n }\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n return { safe: null, reason: `bad-scheme:${url.protocol}` };\n }\n if (url.username.length > 0 || url.password.length > 0) {\n return { safe: null, reason: \"credentials-in-url\" };\n }\n return { safe: value, reason: null };\n}\n\n// ---------------------------------------------------------------------------\n// v1.20.0 (Phase F-DRY): generische Helpers, die in client-registry und\n// global-config bisher dupliziert waren.\n// ---------------------------------------------------------------------------\n\n/** Minimal-Surface f\u00FCr `safeGetState` \u2014 Tests k\u00F6nnen das mocken. */\nexport interface StateReader {\n /** Returns the state for `id`, or `null|undefined` if it does not exist. */\n getStateAsync: (id: string) => Promise<ioBroker.State | null | undefined>;\n}\n\n/**\n * v1.20.0 (F10): try/catch + null-Fallback um `getStateAsync`. Vorher hatten\n * `client-registry.readState` und `global-config.safeGetState` identische\n * Wrapper. Caller extrahieren `.val` selbst, wenn sie nur den Wert wollen.\n *\n * @param adapter Anything that exposes `getStateAsync(id)`.\n * @param id Voller State-ID (mit Namespace) oder relativer Pfad \u2014 wie der\n * Caller das schon bisher \u00FCbergeben hat.\n */\nexport async function safeGetState(adapter: StateReader, id: string): Promise<ioBroker.State | null> {\n try {\n return (await adapter.getStateAsync(id)) ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * v1.20.0 (F9): generischer Parser f\u00FCr Namespace-Prefix-Tail-Kind State-IDs.\n * Vorher hatten `parseClientStateId` und `parseGlobalStateId` identische\n * Prefix-Validierung + Split-Logik. Beide delegieren jetzt hier.\n *\n * Beispiel: `parseAdapterStateId('hassemu.0.clients.abc.mode', 'hassemu.0', 'clients.', 2)`\n * liefert `['abc', 'mode']` (zwei Tail-Parts mit `clients.<id>.<kind>`).\n *\n * @param fullId Voller State-ID aus dem Event.\n * @param namespace Adapter-Namespace (z.B. `hassemu.0`).\n * @param prefix Sub-Pfad nach dem Namespace, **mit** trailing dot (z.B. `clients.`).\n * @param expectedParts Anzahl erwarteter Tail-Segmente (1 f\u00FCr `global.<kind>`, 2 f\u00FCr `clients.<id>.<kind>`).\n * @returns Tail-Segments als Tuple, oder `null` wenn Prefix/Anzahl nicht passt.\n */\nexport function parseAdapterStateId(\n fullId: string,\n namespace: string,\n prefix: string,\n expectedParts: number,\n): string[] | null {\n const fullPrefix = `${namespace}.${prefix}`;\n if (!fullId.startsWith(fullPrefix)) {\n return null;\n }\n const tail = fullId.substring(fullPrefix.length);\n const parts = tail.split(\".\");\n if (parts.length !== expectedParts) {\n return null;\n }\n return parts;\n}\n\n/**\n * v1.25.0 (J1): pure decision-helper f\u00FCr `gcStaleClients` (main.ts).\n * Drei Outcomes:\n * - `'seed'` \u2014 kein lastSeen vorhanden, Timestamp setzen, GC wartet einen Cycle\n * - `'stale'` \u2014 lastSeen \u00E4lter als TTL \u2192 entfernen\n * - `'keep'` \u2014 lastSeen neu genug \u2192 keep\n *\n * Vorher war diese Logik inline in main.ts \u2192 nicht direkt unit-testbar.\n *\n * @param lastSeen Untyped value (kommt aus broker `native.lastSeen`).\n * @param now Aktuelle Zeit in ms.\n * @param ttlMs Stale-TTL in ms.\n */\nexport function decideGcAction(lastSeen: unknown, now: number, ttlMs: number): \"seed\" | \"stale\" | \"keep\" {\n const ls = typeof lastSeen === \"number\" && Number.isFinite(lastSeen) ? lastSeen : 0;\n if (ls === 0) {\n return \"seed\";\n }\n if (now - ls > ttlMs) {\n return \"stale\";\n }\n return \"keep\";\n}\n\n/** Result of {@link decideLegacyVisMigration}. */\nexport type LegacyVisMigration = { kind: \"empty\" } | { kind: \"safe-url\"; safe: string } | { kind: \"unsafe-rejected\" };\n\n/**\n * v1.25.0 (J2): pure decision-helper f\u00FCr die `migrateVisUrlToMode`-Logik\n * (main.ts). Behandelt drei F\u00E4lle:\n * - leer/undefined/null \u2192 `'empty'` (keine Migration n\u00F6tig)\n * - safe-URL \u2192 `'safe-url'` (legacy-URL \u00FCbernehmen)\n * - unsafe (`javascript:`/Credentials/etc.) \u2192 `'unsafe-rejected'` (Manual-Mode setzen, URL verwerfen)\n *\n * Vorher war diese Logik inline in main.ts \u2192 nicht direkt unit-testbar.\n *\n * @param rawValue Untyped value (aus dem legacy `*.visUrl`-State).\n */\nexport function decideLegacyVisMigration(rawValue: unknown): LegacyVisMigration {\n if (rawValue === undefined || rawValue === null || rawValue === \"\") {\n return { kind: \"empty\" };\n }\n const safe = coerceSafeUrl(rawValue);\n if (safe) {\n return { kind: \"safe-url\", safe };\n }\n return { kind: \"unsafe-rejected\" };\n}\n\n/**\n * v1.20.0 (F4): composed `0='---' + sentinels + url-states` \u2014 Grundger\u00FCst\n * der Mode-Dropdowns. Vorher hatten `client-registry.buildModeStates` und\n * `global-config.syncUrlDropdown` identische Composition.\n *\n * @param sentinels Zus\u00E4tzliche Sentinel-Eintr\u00E4ge (z.B. `{ global: 'Follow master', manual: 'Manual URL' }`).\n * @param urlStates Discovered URLs (`{ 'http://x/': 'X', ... }`).\n */\nexport function buildDropdownStates(\n sentinels: Record<string, string>,\n urlStates: Record<string, string>,\n): Record<string, string> {\n return { 0: \"---\", ...sentinels, ...urlStates };\n}\n\n/**\n * Drops oldest entries from a Map until size is below `cap`. Map iteration order\n * in JS is insertion order, so `keys().next()` is the oldest. While-loop is\n * defensive \u2014 if `cap` is lowered at runtime or a bulk-insert pushes multiple\n * entries past the threshold in one call, all overflow gets evicted.\n *\n * v1.32.0: konsolidiert aus `webserver.ts:evictOldest` (private static, while-loop)\n * und `client-registry.ts:recordNewClientIp` (single-shot inline) zu einem shared\n * helper.\n *\n * @param map Map to evict from.\n * @param cap Hard cap \u2014 evicts while `map.size >= cap`.\n */\nexport function evictOldest<V>(map: Map<string, V>, cap: number): void {\n while (map.size >= cap) {\n const oldest = map.keys().next().value;\n if (oldest === undefined) {\n return;\n }\n map.delete(oldest);\n }\n}\n\n/**\n * Escapes the 5 HTML-special characters `<`, `>`, `&`, `\"`, `'` for safe\n * interpolation in HTML element bodies AND in attribute values. Defensive\n * default \u2014 the apostrophe escape covers `href='...'`-attribute uses even\n * if the calling site uses `\"...\"`-attributes today.\n *\n * v1.32.0: konsolidiert aus `landing-page.ts:escapeHtml` (5-Char) und\n * `auth-page.ts:escAttr` (4-Char) zu einem shared helper.\n *\n * @param s Untrusted string to interpolate into HTML.\n */\nexport function escapeHtml(s: string): string {\n return s.replace(/[<>&\"']/g, c => {\n switch (c) {\n case \"<\":\n return \"&lt;\";\n case \">\":\n return \"&gt;\";\n case \"&\":\n return \"&amp;\";\n case '\"':\n return \"&quot;\";\n default:\n return \"&#39;\";\n }\n });\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Boundary coercion helpers for external input.\n *\n * hassemu receives data from HTTP clients, cookies, foreign adapter objects\n * and user writes on states. TypeScript types only guarantee compile-time\n * shape \u2014 these helpers guard runtime reality.\n */\n\nimport crypto from \"node:crypto\";\n\nimport { MODE_GLOBAL, MODE_MANUAL } from \"./constants\";\n\n/**\n * IndieAuth-style redirect_uri validation + HA Companion App whitelist.\n *\n * Three accept paths (matches home-assistant/core/homeassistant/components/auth/indieauth.py:30-55):\n * 1. Same scheme + netloc as client_id (default IndieAuth).\n * 2. Hardcoded whitelist for HA Companion iOS/Android apps \u2014 needed because\n * they use custom URI scheme `homeassistant://` which can never pass rule (1).\n * 3. IndieAuth 4.2.2 \u2014 fetch client_id URL, parse `<link rel=\"redirect_uri\">`\n * tags. NOT implemented here \u2014 LAN emulation can't rely on internet fetch.\n *\n * Always rejects dangerous schemes (javascript:, data:, vbscript:, file:)\n * regardless of any other rule. Defense-in-depth \u2014 defense against an\n * accidental whitelist drift.\n *\n * @param clientId Untrusted `client_id` from OAuth2 query (an absolute URL).\n * @param redirectUri Untrusted `redirect_uri` from OAuth2 query.\n */\nexport function isValidRedirectUri(clientId: string, redirectUri: string): boolean {\n if (typeof clientId !== \"string\" || typeof redirectUri !== \"string\") {\n return false;\n }\n if (clientId.length === 0 || redirectUri.length === 0) {\n return false;\n }\n if (redirectUri.length > 2048 || clientId.length > 2048) {\n return false;\n }\n // Dangerous schemes \u2014 never accept regardless of any other rule.\n const lower = redirectUri.toLowerCase();\n const forbidden = [\"javascript:\", \"data:\", \"vbscript:\", \"file:\"];\n for (const scheme of forbidden) {\n if (lower.startsWith(scheme)) {\n return false;\n }\n }\n\n // (2) HA Companion App whitelist \u2014 must be checked before (1) because\n // these apps use `homeassistant://` which has no http(s) netloc to match.\n // Source: home-assistant/core indieauth.py:39-50.\n if (clientId === \"https://home-assistant.io/iOS\" && redirectUri === \"homeassistant://auth-callback\") {\n return true;\n }\n if (\n clientId === \"https://home-assistant.io/android\" &&\n (redirectUri === \"homeassistant://auth-callback\" ||\n redirectUri === \"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android\" ||\n redirectUri === \"https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android\")\n ) {\n return true;\n }\n\n // (1) Default IndieAuth rule \u2014 same scheme + netloc.\n try {\n const cid = new URL(clientId);\n const ru = new URL(redirectUri);\n return cid.protocol === ru.protocol && cid.host === ru.host;\n } catch {\n return false;\n }\n}\n\n/**\n * v1.22.0 (F5): vormals in webserver.ts. Constant-time string comparison\n * for credential checks. Length-leak-resistant via SHA-256-Digest-Vergleich:\n * beide Inputs werden auf eine fixe 32-Byte-L\u00E4nge gehasht, dann timing-safe\n * verglichen.\n *\n * v1.16.0 (C6): vorher `if (ab.length !== bb.length) return false` VOR\n * timingSafeEqual \u2014 die L\u00E4ngen-Differenz war \u00FCber Response-Timing\n * erschn\u00FCffelbar.\n *\n * @param a First string to compare.\n * @param b Second string to compare.\n */\nexport function safeStringEqual(a: string, b: string): boolean {\n const ah = crypto.createHash(\"sha256\").update(a, \"utf8\").digest();\n const bh = crypto.createHash(\"sha256\").update(b, \"utf8\").digest();\n return crypto.timingSafeEqual(ah, bh);\n}\n\n/**\n * \u201ENo-choice\"-Marker: User hat den Default-Eintrag `0='---'` (oder eine seiner\n * Repr\u00E4sentationen) gew\u00E4hlt. Behandelt sowohl die numerische `0` (Admin-UI\n * mit `type: mixed` Dropdowns), die String-Variante `'0'` und den leeren String.\n *\n * Wird in beiden Mode-Handlers (client-registry, global-config) gleich behandelt\n * \u2014 vor v1.8.0 war die Logik 4\u00D7 dupliziert. Jeder andere Wert ist ein \u201Eechter\"\n * User-Input und muss validiert werden.\n *\n * @param value Untrusted input vom Mode-State (numeric 0 / string '0' / '' / URL / sentinel).\n */\nexport function isNoChoice(value: unknown): boolean {\n return value === 0 || value === \"0\" || value === \"\";\n}\n\n/**\n * Coerce to a finite number, or null. Rejects NaN, Infinity, non-numeric strings.\n *\n * @param value Untrusted input.\n */\n// v1.9.0 (E8): nur dezimale Zahlen \u2014 `Number()` w\u00FCrde sonst auch\n// '0x1FBB' (HEX) und '8.123e3' (Exponential) akzeptieren. In url-discovery\n// f\u00FCr Port-Felder w\u00E4re HEX-Acceptance Schaden-Vektor.\nconst DECIMAL_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/;\n\n/**\n * Coerce to a finite number, or null. Rejects NaN, Infinity, non-decimal\n * strings (HEX, exponential, scientific notation).\n *\n * @param value Untrusted input.\n */\nexport function coerceFiniteNumber(value: unknown): number | null {\n if (typeof value === \"number\") {\n return Number.isFinite(value) ? value : null;\n }\n if (typeof value === \"string\" && DECIMAL_NUMBER_RE.test(value)) {\n const n = Number(value);\n return Number.isFinite(n) ? n : null;\n }\n return null;\n}\n\n/**\n * Coerce to a non-empty string, or null.\n *\n * @param value Untrusted input.\n */\nexport function coerceString(value: unknown): string | null {\n if (typeof value === \"string\" && value.length > 0) {\n return value;\n }\n return null;\n}\n\n/**\n * Coerce to a boolean. Only accepts actual `true` / `false`.\n *\n * @param value Untrusted input.\n */\nexport function coerceBoolean(value: unknown): boolean | null {\n if (typeof value === \"boolean\") {\n return value;\n }\n return null;\n}\n\n/**\n * Guard for plain objects (not arrays, not null).\n *\n * @param value Untrusted input.\n */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nconst UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/**\n * Coerce to a UUID string, or null. Accepts any UUID variant.\n *\n * @param value Untrusted input.\n */\nexport function coerceUuid(value: unknown): string | null {\n if (typeof value !== \"string\") {\n return null;\n }\n return UUID_REGEX.test(value) ? value.toLowerCase() : null;\n}\n\n/** Result of {@link parseManualUrlWrite}. */\nexport type ManualUrlWriteResult = { ok: true; safe: string | null } | { ok: false };\n\n/**\n * Validates a write to a `manualUrl` state. Empty / null / undefined \u2192 clear\n * (`safe: null`). Otherwise must pass {@link coerceSafeUrl}; if not \u2192 `ok: false`\n * so the caller can reject + revert. Centralises the validation that both\n * `ClientRegistry.handleManualUrlWrite` and `GlobalConfig.handleManualUrlWrite`\n * share \u2014 caller still owns logging + setState because the prefixes/state-IDs\n * differ.\n *\n * @param rawValue Value written to the state.\n */\nexport function parseManualUrlWrite(rawValue: unknown): ManualUrlWriteResult {\n const empty = rawValue === \"\" || rawValue === null || rawValue === undefined;\n if (empty) {\n return { ok: true, safe: null };\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n return { ok: false };\n }\n return { ok: true, safe };\n}\n\n/** Result of {@link parseModeWrite}. */\nexport type ModeWriteResult =\n | { kind: \"no-choice\" }\n | { kind: \"sentinel\"; value: string }\n | { kind: \"url\"; value: string }\n | { kind: \"rejected-non-string\" }\n | { kind: \"rejected-disallowed-sentinel\"; value: string }\n | { kind: \"rejected-unsafe-url\"; raw: string };\n\n/**\n * v1.23.0 (F2): zentralisierte Validierung f\u00FCr Mode-Writes. Vorher hatten\n * `ClientRegistry.handleModeWrite` und `GlobalConfig.handleModeWrite` ~80%\n * der Logik dupliziert (no-choice, non-string-reject, sentinel-check, URL-\n * coerce). Beide nutzen jetzt diesen Helper und steuern nur ihre eigenen\n * State-IDs / Logging-Prefixes / erlaubte Sentinels.\n *\n * `allowedSentinels` ist die Liste der zul\u00E4ssigen non-URL Mode-Werte \u2014\n * client-registry erlaubt z.B. `[MODE_GLOBAL, MODE_MANUAL]`, global-config\n * nur `[MODE_MANUAL]` (MODE_GLOBAL w\u00E4re self-referential).\n *\n * @param rawValue Wert vom State-Write.\n * @param allowedSentinels Erlaubte Non-URL-Sentinels.\n */\nexport function parseModeWrite(rawValue: unknown, allowedSentinels: readonly string[]): ModeWriteResult {\n if (isNoChoice(rawValue)) {\n return { kind: \"no-choice\" };\n }\n if (typeof rawValue !== \"string\") {\n return { kind: \"rejected-non-string\" };\n }\n // String-Sentinels haben Vorrang vor URL-Coerce.\n if (allowedSentinels.includes(rawValue)) {\n return { kind: \"sentinel\", value: rawValue };\n }\n // Disallowed-Sentinel-Detection: wenn der Caller MODE_GLOBAL/MODE_MANUAL\n // als known-strings hat, aber sie nicht in allowedSentinels sind, melden\n // wir das explizit (f\u00FCr Self-Referential-Check in global-config).\n if (rawValue === MODE_GLOBAL || rawValue === MODE_MANUAL) {\n return { kind: \"rejected-disallowed-sentinel\", value: rawValue };\n }\n const safe = coerceSafeUrl(rawValue);\n if (!safe) {\n return { kind: \"rejected-unsafe-url\", raw: rawValue };\n }\n return { kind: \"url\", value: safe };\n}\n\n/**\n * Coerce to a safe redirect URL, or null.\n *\n * Requirements:\n * - http:// or https:// scheme (no javascript:, data:, file:, etc.)\n * - Parseable by URL()\n * - No embedded credentials (user:pass@host)\n * - Max 2048 chars\n *\n * @param value Untrusted input.\n */\nexport function coerceSafeUrl(value: unknown): string | null {\n if (typeof value !== \"string\" || value.length === 0 || value.length > 2048) {\n return null;\n }\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return null;\n }\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n return null;\n }\n if (url.username.length > 0 || url.password.length > 0) {\n return null;\n }\n return value;\n}\n\n// ---------------------------------------------------------------------------\n// v1.20.0 (Phase F-DRY): generische Helpers, die in client-registry und\n// global-config bisher dupliziert waren.\n// ---------------------------------------------------------------------------\n\n/** Minimal-Surface f\u00FCr `safeGetState` \u2014 Tests k\u00F6nnen das mocken. */\nexport interface StateReader {\n /** Returns the state for `id`, or `null|undefined` if it does not exist. */\n getStateAsync: (id: string) => Promise<ioBroker.State | null | undefined>;\n}\n\n/**\n * v1.20.0 (F10): try/catch + null-Fallback um `getStateAsync`. Vorher hatten\n * `client-registry.readState` und `global-config.safeGetState` identische\n * Wrapper. Caller extrahieren `.val` selbst, wenn sie nur den Wert wollen.\n *\n * @param adapter Anything that exposes `getStateAsync(id)`.\n * @param id Voller State-ID (mit Namespace) oder relativer Pfad \u2014 wie der\n * Caller das schon bisher \u00FCbergeben hat.\n */\nexport async function safeGetState(adapter: StateReader, id: string): Promise<ioBroker.State | null> {\n try {\n return (await adapter.getStateAsync(id)) ?? null;\n } catch {\n return null;\n }\n}\n\n/**\n * v1.20.0 (F9): generischer Parser f\u00FCr Namespace-Prefix-Tail-Kind State-IDs.\n * Vorher hatten `parseClientStateId` und `parseGlobalStateId` identische\n * Prefix-Validierung + Split-Logik. Beide delegieren jetzt hier.\n *\n * Beispiel: `parseAdapterStateId('hassemu.0.clients.abc.mode', 'hassemu.0', 'clients.', 2)`\n * liefert `['abc', 'mode']` (zwei Tail-Parts mit `clients.<id>.<kind>`).\n *\n * @param fullId Voller State-ID aus dem Event.\n * @param namespace Adapter-Namespace (z.B. `hassemu.0`).\n * @param prefix Sub-Pfad nach dem Namespace, **mit** trailing dot (z.B. `clients.`).\n * @param expectedParts Anzahl erwarteter Tail-Segmente (1 f\u00FCr `global.<kind>`, 2 f\u00FCr `clients.<id>.<kind>`).\n * @returns Tail-Segments als Tuple, oder `null` wenn Prefix/Anzahl nicht passt.\n */\nexport function parseAdapterStateId(\n fullId: string,\n namespace: string,\n prefix: string,\n expectedParts: number,\n): string[] | null {\n const fullPrefix = `${namespace}.${prefix}`;\n if (!fullId.startsWith(fullPrefix)) {\n return null;\n }\n const tail = fullId.substring(fullPrefix.length);\n const parts = tail.split(\".\");\n if (parts.length !== expectedParts) {\n return null;\n }\n return parts;\n}\n\n/**\n * v1.25.0 (J1): pure decision-helper f\u00FCr `gcStaleClients` (main.ts).\n * Drei Outcomes:\n * - `'seed'` \u2014 kein lastSeen vorhanden, Timestamp setzen, GC wartet einen Cycle\n * - `'stale'` \u2014 lastSeen \u00E4lter als TTL \u2192 entfernen\n * - `'keep'` \u2014 lastSeen neu genug \u2192 keep\n *\n * Vorher war diese Logik inline in main.ts \u2192 nicht direkt unit-testbar.\n *\n * @param lastSeen Untyped value (kommt aus broker `native.lastSeen`).\n * @param now Aktuelle Zeit in ms.\n * @param ttlMs Stale-TTL in ms.\n */\nexport function decideGcAction(lastSeen: unknown, now: number, ttlMs: number): \"seed\" | \"stale\" | \"keep\" {\n const ls = typeof lastSeen === \"number\" && Number.isFinite(lastSeen) ? lastSeen : 0;\n if (ls === 0) {\n return \"seed\";\n }\n if (now - ls > ttlMs) {\n return \"stale\";\n }\n return \"keep\";\n}\n\n/** Result of {@link decideLegacyVisMigration}. */\nexport type LegacyVisMigration = { kind: \"empty\" } | { kind: \"safe-url\"; safe: string } | { kind: \"unsafe-rejected\" };\n\n/**\n * v1.25.0 (J2): pure decision-helper f\u00FCr die `migrateVisUrlToMode`-Logik\n * (main.ts). Behandelt drei F\u00E4lle:\n * - leer/undefined/null \u2192 `'empty'` (keine Migration n\u00F6tig)\n * - safe-URL \u2192 `'safe-url'` (legacy-URL \u00FCbernehmen)\n * - unsafe (`javascript:`/Credentials/etc.) \u2192 `'unsafe-rejected'` (Manual-Mode setzen, URL verwerfen)\n *\n * Vorher war diese Logik inline in main.ts \u2192 nicht direkt unit-testbar.\n *\n * @param rawValue Untyped value (aus dem legacy `*.visUrl`-State).\n */\nexport function decideLegacyVisMigration(rawValue: unknown): LegacyVisMigration {\n if (rawValue === undefined || rawValue === null || rawValue === \"\") {\n return { kind: \"empty\" };\n }\n const safe = coerceSafeUrl(rawValue);\n if (safe) {\n return { kind: \"safe-url\", safe };\n }\n return { kind: \"unsafe-rejected\" };\n}\n\n/**\n * v1.20.0 (F4): composed `0='---' + sentinels + url-states` \u2014 Grundger\u00FCst\n * der Mode-Dropdowns. Vorher hatten `client-registry.buildModeStates` und\n * `global-config.syncUrlDropdown` identische Composition.\n *\n * @param sentinels Zus\u00E4tzliche Sentinel-Eintr\u00E4ge (z.B. `{ global: 'Follow master', manual: 'Manual URL' }`).\n * @param urlStates Discovered URLs (`{ 'http://x/': 'X', ... }`).\n */\nexport function buildDropdownStates(\n sentinels: Record<string, string>,\n urlStates: Record<string, string>,\n): Record<string, string> {\n return { 0: \"---\", ...sentinels, ...urlStates };\n}\n\n/**\n * Drops oldest entries from a Map until size is below `cap`. Map iteration order\n * in JS is insertion order, so `keys().next()` is the oldest. While-loop is\n * defensive \u2014 if `cap` is lowered at runtime or a bulk-insert pushes multiple\n * entries past the threshold in one call, all overflow gets evicted.\n *\n * v1.32.0: konsolidiert aus `webserver.ts:evictOldest` (private static, while-loop)\n * und `client-registry.ts:recordNewClientIp` (single-shot inline) zu einem shared\n * helper.\n *\n * @param map Map to evict from.\n * @param cap Hard cap \u2014 evicts while `map.size >= cap`.\n */\nexport function evictOldest<V>(map: Map<string, V>, cap: number): void {\n while (map.size >= cap) {\n const oldest = map.keys().next().value;\n if (oldest === undefined) {\n return;\n }\n map.delete(oldest);\n }\n}\n\n/**\n * Escapes the 5 HTML-special characters `<`, `>`, `&`, `\"`, `'` for safe\n * interpolation in HTML element bodies AND in attribute values. Defensive\n * default \u2014 the apostrophe escape covers `href='...'`-attribute uses even\n * if the calling site uses `\"...\"`-attributes today.\n *\n * v1.32.0: konsolidiert aus `landing-page.ts:escapeHtml` (5-Char) und\n * `auth-page.ts:escAttr` (4-Char) zu einem shared helper.\n *\n * @param s Untrusted string to interpolate into HTML.\n */\nexport function escapeHtml(s: string): string {\n return s.replace(/[<>&\"']/g, c => {\n switch (c) {\n case \"<\":\n return \"&lt;\";\n case \">\":\n return \"&gt;\";\n case \"&\":\n return \"&amp;\";\n case '\"':\n return \"&quot;\";\n default:\n return \"&#39;\";\n }\n });\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["crypto"]
7
7
  }
@@ -53,21 +53,6 @@ class UrlDiscovery {
53
53
  this.adapter = adapter;
54
54
  this.onChange = onChange;
55
55
  }
56
- /** Returns a copy of the last collected URL states. Does not trigger collection. */
57
- getCached() {
58
- return { ...this.cached };
59
- }
60
- /**
61
- * Returns the first discovered URL (insertion order), or null if the cache
62
- * is empty. Used by the global-config bulk-sync when the master switch is
63
- * flipped off — clients fall back to a sensible default URL.
64
- */
65
- getFirstDiscoveredUrl() {
66
- for (const url of Object.keys(this.cached)) {
67
- return url;
68
- }
69
- return null;
70
- }
71
56
  /**
72
57
  * Schedules a refresh after `debounceMs`. Multiple calls within the window coalesce into one.
73
58
  *