[
  {
    "path": ".gitignore",
    "content": "*~\nnode_modules\ndist\ndocs/_build\n"
  },
  {
    "path": ".prettierignore",
    "content": "dist\nnpm-shrinkwrap.json\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md — GenieACS\n\nGenieACS is a TR-069 Auto Configuration Server for remote management of CPE\ndevices (routers, modems, gateways). TypeScript codebase compiled with esbuild,\nbacked by MongoDB.\n\n## Architecture Overview\n\nFour services share a single MongoDB instance:\n\n- **CWMP** (port 7547) — TR-069 protocol handler; manages device sessions\n- **NBI** (port 7557) — Northbound REST API for external consumers\n- **FS** (port 7567) — File server for firmware/config (GridFS-backed)\n- **UI** (port 3000) — Web interface (Koa backend + Mithril.js SPA frontend)\n\nKey subsystems: expression engine (`lib/common/expression/`) compiles a\nLisp-like DSL used for queries, config, and authorization; session engine\n(`lib/session.ts`) drives CWMP interactions via declarations rather than\nimperative RPCs; sandbox (`lib/sandbox.ts`) runs user-defined provision scripts\nin `vm.Script` with deterministic replay.\n\nRead `ARCHITECTURE.md` for a full map of the codebase when working on unfamiliar\nareas. It covers service boundaries, the expression pipeline, the path system,\nthe CWMP session state machine, the database layer, and architectural\ninvariants.\n\n## Project Structure\n\n- `lib/` — Core server-side logic\n- `lib/common/` — Shared code (runs in both Node.js and browser)\n- `lib/db/` — MongoDB database layer\n- `lib/ui/` — UI backend helpers\n- `ui/` — Frontend SPA (Mithril.js)\n- `bin/` — Service entry points (5 executables)\n- `build/` — Build scripts (esbuild pipeline)\n- `test/` — Unit tests (node:test)\n- `docs/` — User docs (Sphinx/reStructuredText)\n- `public/` — Static assets (favicon, logo)\n\n## Build / Lint / Test Commands\n\n```bash\nnpm run build # Production build (esbuild pipeline -> dist/)\nNODE_ENV=development npm run build # Dev build (no minification)\nnpm run lint # Prettier + ESLint + tsc --noEmit in parallel\nnpm test # Compile tests with esbuild, run with node --test\n```\n\n### Running a Single Test File\n\n```bash\nesbuild --log-level=warning --bundle --platform=node --target=node18 \\\n  --packages=external --sourcemap=inline --outdir=test test/path.ts \\\n  && node --test --enable-source-maps test/path.js \\\n  && rm test/path.js\n```\n\n### Running a Single Test Case\n\n```bash\nesbuild --log-level=warning --bundle --platform=node --target=node18 \\\n  --packages=external --sourcemap=inline --outdir=test test/path.ts \\\n  && node --test --enable-source-maps --test-name-pattern=\"^parse$\" test/path.js \\\n  && rm test/path.js\n```\n\n### Lint Sub-commands\n\n```bash\nprettier --prose-wrap always --write .\neslint 'bin/*.ts' 'lib/**/*.ts' 'ui/**/*.ts' 'test/**/*.ts' 'build/**/*.ts'\ntsc --noEmit\n```\n\n## Before Committing\n\nRead `CONTRIBUTING.md` and ensure your changes comply with it. In particular:\n\n- Run `npm run lint` and `npm test` and fix any failures.\n- Follow the code style, naming, import, and comment conventions documented\n  there.\n- Use the Conventional Commits format for commit messages.\n"
  },
  {
    "path": "ARCHITECTURE.md",
    "content": "# Architecture\n\nThis document describes the high-level architecture of GenieACS. If you want to\nfamiliarize yourself with the codebase, you are in the right place.\n\n## Bird's Eye View\n\nGenieACS is a TR-069 Auto Configuration Server (ACS). It manages CPE devices\n(routers, modems, gateways) using the CWMP protocol (TR-069). The system\nconsists of four network-facing services that share a MongoDB database:\n\n```\n                  +------------+\n  CPE Devices --->| CWMP (7547)|-+\n                  +------------+ |\n                  +-----------+  |     +---------+\n  CPE Devices --->| FS (7567) |--+---->| MongoDB |\n                  +-----------+  |     +---------+\n                  +-----------+  |\n  OSS / Scripts ->| NBI (7557)|--+\n                  +-----------+  |\n                  +-----------+  |\n  Administrators->| UI (3000) |--+\n                  +-----------+\n```\n\n- **CWMP** -- The core TR-069 protocol server. CPE devices connect here to\n  report their state and receive configuration instructions via SOAP/XML over\n  HTTP.\n- **NBI** -- Northbound Interface. A REST API for external systems (OSS/BSS,\n  scripts, automation) to manage devices, tasks, presets, and configuration\n  programmatically.\n- **FS** -- File Server. Serves firmware images and configuration files to CPE\n  devices during download operations.\n- **UI** -- Web interface. A Koa-based backend serving a Mithril.js single-page\n  application for administrators to browse devices, manage configuration, and\n  trigger operations.\n\nAll four services follow the same process model: a primary process forks\nconfigurable worker processes via Node.js `cluster` (see `cluster.ts`). Workers\nconnect to MongoDB and start an HTTP(S) server.\n\n## Code Map\n\n```\nbin/                        Service entry points (5 executables)\nlib/                        All backend logic\n  common/                   Shared utilities (expression engine, Path, errors)\n    expression.ts           Expression class hierarchy (base + subclasses)\n    expression/             Expression parser, evaluator, normalizer, minimizer\n  cwmp/                     CWMP-service-specific DB and caching\n  db/                       Database layer (MongoDB collections, query synthesis)\n  ui/                       UI-service-specific API, DB, and caching\n  types/                    (empty, reserved)\nui/                         Frontend SPA (Mithril.js)\n  components/               Reusable UI components (parameter, tags, ping, etc.)\n  css/                      Stylesheets (vanilla CSS)\n  icons/                    SVG icons (compiled into a sprite)\ntest/                       Unit tests (Node.js native test runner)\nbuild/                      Build scripts (esbuild-based)\npublic/                     Static assets (logo, favicon)\n```\n\n### Entry Points (`bin/`)\n\nEach file in `bin/` bootstraps one service. They are structurally identical:\ninitialize logging, read config, fork workers in the primary process, connect to\nMongoDB and start the HTTP server in each worker.\n\n- `genieacs-cwmp.ts` -- CWMP service. Unique in that it disables HTTP keep-alive\n  (`keepAliveTimeout: 0`) and provides custom `onConnection` / `onClientError`\n  hooks for TR-069 session lifecycle management.\n- `genieacs-nbi.ts` -- NBI service. Straightforward REST API server.\n- `genieacs-fs.ts` -- File server. The leanest service; does not use the\n  extensions subsystem.\n- `genieacs-ui.ts` -- UI service. Wraps the Koa application as the HTTP\n  listener.\n- `genieacs-ext.ts` -- **Not a service.** This is a child process worker spawned\n  by the extensions subsystem. It communicates with its parent via IPC,\n  executing user-defined extension scripts in isolation.\n\n### The Expression System (`lib/common/expression.ts`, `lib/common/expression/`)\n\nThe `Expression` class (defined in `lib/common/expression.ts`) is the most\nimportant abstraction in the codebase. It uses a typed class hierarchy:\n`Expression.Literal` (wraps `string | number | boolean | null`),\n`Expression.Parameter` (wraps a `Path`), `Expression.Binary` (operator +\nleft/right), `Expression.Unary` (operator + operand), `Expression.FunctionCall`\n(name + args), and `Expression.Conditional` (condition/then/otherwise). For\nexample, in the SQL-like text syntax:\n\n```\nDevice.ModelName = \"BrandX\" AND Events.Inform > 1000\n```\n\nExpressions are used pervasively:\n\n- **Query/filter language** -- Database queries for devices, faults, presets,\n  etc. are all represented as expressions and compiled to MongoDB filters.\n- **Configuration values** -- Config entries and preset preconditions are\n  expressions, enabling dynamic evaluation.\n- **Authorization** -- Permission filters and validators are expressions.\n- **Pagination cursors** -- Keyset pagination boundaries are expressed as filter\n  expressions.\n\nThe expression pipeline flows through four modules:\n\n1. `parser.ts` -- Parses a SQL-like text syntax into the AST using a hand-rolled\n   recursive descent parser with a `Cursor`-based scanner. Also provides\n   `stringifyExpression()` for serialization. The `map()` / `mapAsync()`\n   tree-walking primitives are abstract methods on the `Expression` class, and\n   `stringify()` is now `Expression.toString()`.\n2. `normalize.ts` -- Algebraic normalization using exact rational polynomial\n   arithmetic over native `bigint` values. The `Polynomial` class extends\n   `Expression` as an intermediate representation. Ensures equivalent\n   expressions have the same canonical form (e.g., `a + 2 > b` and `a > b - 2`\n   normalize identically).\n3. `synth.ts` -- Boolean logic minimization via the Espresso algorithm\n   (espresso-iisojs). Converts expressions to minimal sum-of-products form with\n   three-valued logic (true/false/null). Handles domain-specific constraints\n   like comparison ordering and LIKE pattern relationships.\n4. `evaluate.ts` -- Runtime evaluation. The `reduce()` function evaluates\n   operators on literal values and supports partial evaluation (returns a\n   reduced expression if some values are unknown). Parameter resolution is\n   handled by the `Expression.evaluate()` method (in `lib/common/expression.ts`)\n   which calls `reduce()` after mapping children via a user-supplied callback.\n\n`pagination.ts` implements cursor-based pagination by generating filter\nexpressions from sort-key bookmarks.\n\n### The Path System (`lib/common/path.ts`, `lib/common/path-set.ts`)\n\n`Path` represents a TR-069 parameter path (e.g., `Device.WiFi.SSID.1.Name`).\nPaths can contain wildcards (`*`) and alias expressions (`[key:value]`) for\nquery-based addressing.\n\nKey design decisions:\n\n- **Cached** -- `Path.parse()` caches instances in a two-generation LRU cache\n  rotated every 120 seconds. The constructor is public (used directly by the\n  parser and by methods like `slice()`, `concat()`, and `stripAlias()`).\n- **Bitmask encoding** -- The `wildcard` and `alias` fields are bitfields for\n  O(1) segment-type checking. This limits paths to 32 segments. A `colon` field\n  tracks the number of attribute path segments (after a `:` separator), enabling\n  `paramLength` and `attrLength` accessors.\n- **Immutable** -- Segment arrays are `Object.freeze()`-d.\n\n`PathSet` is a multi-indexed collection of paths supporting pattern-matching\nqueries. It maintains separate `paramSegmentIndex` and `attrSegmentIndex` arrays\n(one `Map<string, Set<Path>>` per position), plus a `stringIndex` map. The\n`find()` method takes bitmasks to control which segments require exact matches\nvs. wildcard compatibility, then uses set intersection across the smallest index\nsets. A higher-level `findCompat()` method computes the appropriate bitmasks for\nsuperset/subset matching.\n\n### CWMP Protocol Layer (`lib/cwmp.ts`, `lib/soap.ts`, `lib/xml-parser.ts`)\n\n`xml-parser.ts` is a custom single-pass XML parser (no DOM). It scans\ncharacter-by-character with bitwise state flags, building a tree of elements\nwith namespace support. Does not support CDATA.\n\n`soap.ts` handles both parsing CPE SOAP messages and generating ACS SOAP\nresponses. It dispatches on the SOAP body's method name to type-specific parsers\nfor Inform, TransferComplete, GetParameterNamesResponse, etc. Supports CWMP\nversions 1.0 through 1.4.\n\n`cwmp.ts` is the HTTP-level CWMP request handler. It manages the session state\nmachine:\n\n1. **State 0** -- Expects an Inform. Authenticates the device (Basic or Digest\n   auth, configurable via expression). Acquires a distributed lock. Loads device\n   data from MongoDB. Sends InformResponse.\n2. **State 1** -- Waits for the CPE to send an empty POST (ready for ACS RPCs).\n   Processes any TransferComplete messages.\n3. **State 2** -- The ACS drives RPCs (GetParameterNames, GetParameterValues,\n   SetParameterValues, AddObject, DeleteObject, Download, Reboot, FactoryReset).\n   The CPE responds to each.\n\nSession persistence across TCP disconnects: when a socket closes mid-session,\nthe entire `SessionContext` is serialized to Redis (the MongoDB `cache`\ncollection) and restored when the CPE reconnects (identified by a session\ncookie).\n\n### Session Engine (`lib/session.ts`)\n\nThe session engine implements the **declaration-driven data fetching** pattern.\nRather than issuing RPCs imperatively, provisions create `Declaration` objects\nstating what paths and attributes they need to read or write. The engine then:\n\n1. Processes all declarations into a `SyncState` -- a structured plan of which\n   parameters to refresh, which values to set, which instances to create/delete,\n   etc.\n2. Generates the minimal set of CWMP RPCs needed to fulfill the plan.\n3. After each RPC response, updates `DeviceData` and re-evaluates.\n4. Iterates until all declarations are satisfied.\n\nThe preset system (`applyPresets` in `cwmp.ts`) implements a policy engine:\npresets are rules with precondition expressions that, when matched, contribute\nprovisions to the session. After provisions execute, if device data changed,\npresets are re-evaluated (up to 4 cycles to prevent infinite loops).\n\n### Provisions and Virtual Parameters\n\n**Provisions** are the unit of configuration intent. Built-in provisions\n(`default-provisions.ts`) include `refresh`, `value`, `tag`, `reboot`, `reset`,\n`download`, and `instances`. Custom provisions are user-defined JavaScript\nscripts stored in the `provisions` MongoDB collection.\n\n**Virtual parameters** are scripts that present computed/derived values as if\nthey were real device parameters under the `VirtualParameters.*` namespace. They\nrun in two phases: a \"get\" phase reads real parameters and returns a computed\nvalue; a \"set\" phase translates a desired value into real parameter changes.\nVirtual parameters can reference other virtual parameters (up to depth 8).\n\n### Sandbox (`lib/sandbox.ts`)\n\nThe sandbox provides a secure execution environment for provision and virtual\nparameter scripts using `vm.Script` with a 50ms timeout. It uses a\n**replay-based execution model**:\n\n1. A script runs and calls `declare()` to request data.\n2. When `commit()` is called, the script throws a sentinel symbol and exits.\n3. The engine fetches the requested data via CWMP RPCs.\n4. The script is **re-run from the beginning** with the fetched data available.\n5. Earlier `declare()` calls return cached results; the script progresses\n   further.\n6. This repeats until the script completes without throwing.\n\nThe sandbox API: `declare(path, timestamps, values)` returns a\n`ParameterWrapper` proxy; `clear(path, timestamp, attributes)` invalidates\ncached data; `ext(...args)` calls external extensions (results are cached per\nrevision to survive replays); `commit()` explicitly triggers a fetch cycle.\n`Math.random()` is replaced with a seeded PRNG for determinism.\n\n### Device Data Model (`lib/types.ts`, `lib/device.ts`)\n\n`DeviceData` is the in-memory working copy of a device's parameter tree during a\nsession:\n\n- `paths: PathSet` -- All known parameter paths.\n- `timestamps: VersionedMap<Path, number>` -- When each path was last refreshed.\n- `attributes: VersionedMap<Path, Attributes>` -- Per-path attributes (object,\n  writable, value, notification, accessList), each paired with a timestamp.\n- `trackers` / `changes` -- Change tracking for re-evaluation.\n\n`VersionedMap` (in `versioned-map.ts`) provides multi-revision snapshots,\nenabling the sandbox replay model where scripts may be re-run at different\nrevision levels.\n\n`device.ts` handles setting and clearing parameter data with invariant\nenforcement (e.g., if `value` is set, `object` is forced to 0; parent paths are\nensured to exist). The `unpack()` function resolves wildcards and alias paths\nagainst concrete device data.\n\n### NBI (`lib/nbi.ts`)\n\nA raw Node.js HTTP listener (no framework) with regex-based URL routing.\nEndpoints include CRUD for presets, provisions, virtual parameters, objects, and\nfiles; device task management (with optional synchronous execution via\nconnection request); fault management; generic collection querying; and ping.\n\nThe NBI uses MongoDB-style JSON queries directly. For the `devices` collection,\n`query.ts` expands user-friendly queries by auto-appending `._value` to\nparameter paths and generating multi-type interpretations (string, number, date,\nregex) for filter values.\n\n### File Server (`lib/fs.ts`)\n\nA minimal HTTP file server reading from MongoDB GridFS. Supports GET/HEAD with\nfull HTTP caching (ETag, Last-Modified, If-None-Match, If-Modified-Since) and\nRange requests (HTTP 206) for partial content downloads. Files are cached\nin-memory via memoization.\n\n### UI Backend (`lib/ui.ts`, `lib/ui/`)\n\nA Koa application with JWT authentication, role-based authorization, and a rich\nCRUD API under `/api/`. The root route serves an HTML shell that bootstraps the\nMithril.js SPA with injected config, user info, and hashed asset filenames.\n\n`lib/ui/api.ts` defines generic CRUD endpoints for all resource types (devices,\npresets, provisions, files, config, users, permissions, faults, tasks) with\nauthorization checks at every level. Specialized endpoints handle file\nupload/download, synchronous task execution, tag management, password changes,\nand CSV export.\n\n`lib/ui/db.ts` translates between the UI's flat parameter representation and\nMongoDB's nested document structure. The `flattenDevice()` function is the key\ntransformation: it recursively walks the nested device document and produces a\nflat key-value map with colon-delimited attribute keys (e.g.,\n`\"Device.WiFi.SSID.1.Name\" -> value`,\n`\"Device.WiFi.SSID.1.Name:type\" -> \"xsd:string\"`,\n`\"Device.WiFi.SSID.1.Name:writable\" -> true`). The `FlatDevice` type is\n`Record<string, Value>` where `Value = string | number | boolean | null`.\n\n`lib/ui/local-cache.ts` caches permissions, users, and config in-process with\nhash-based revision tracking. The `getConfig()` function uses typed overloads --\nit takes a typed default value (`string`, `number`, or `boolean`) and an\nexpression evaluation callback, returning a value of the same type as the\ndefault.\n\n### Database Layer (`lib/db/`)\n\n`db/db.ts` is the single entry point to MongoDB. It manages 14 collections:\n\n| Collection          | Purpose                          |\n| ------------------- | -------------------------------- |\n| `devices`           | CPE device parameter trees       |\n| `presets`           | Configuration rules              |\n| `provisions`        | Provision scripts                |\n| `virtualParameters` | Virtual parameter scripts        |\n| `objects`           | Generic objects                  |\n| `tasks`             | Queued device management tasks   |\n| `faults`            | Error records with retry state   |\n| `operations`        | In-flight async operations       |\n| `files` (GridFS)    | Firmware images and config files |\n| `permissions`       | RBAC rules                       |\n| `users`             | User accounts                    |\n| `config`            | Key-value configuration          |\n| `cache`             | Distributed cache (TTL index)    |\n| `locks`             | Distributed locks (TTL index)    |\n\n`db/synth.ts` is the sophisticated expression-to-MongoDB query compiler. It\nnormalizes expressions, converts them to a Boolean satisfiability representation\nusing the `Clause` hierarchy, minimizes via Espresso, and emits MongoDB\n`$and`/`$or`/`$not` filter objects. This is used by the UI backend.\n\n`cwmp/db.ts` handles CWMP-specific persistence: loading the nested device\ndocument into `DeviceData` (`fetchDevice`), diffing changes back into MongoDB\nupdate operations (`saveDevice`), and managing faults, tasks, and operations.\n\n### Query Systems\n\nThere are two separate query paths:\n\n1. **NBI queries** (`lib/query.ts`) -- Processes MongoDB-style JSON queries from\n   external API consumers. Expands parameter names, generates multi-type value\n   interpretations, and passes through to MongoDB directly.\n\n2. **UI/Expression queries** (`lib/db/synth.ts`) -- Compiles the internal\n   expression language into optimized MongoDB filters via Boolean minimization.\n   This is the more sophisticated path, used by the UI backend.\n\n### Frontend (`ui/`)\n\nA Mithril.js SPA with hash-based routing (`#!/`). Key modules:\n\n- `app.ts` -- Route definitions. The `pagify()` function wraps each page into a\n  `RouteResolver` that handles initialization, error boundaries, and data\n  fulfillment.\n- `layout.ts` -- Top-level layout (header, navigation, content, overlay).\n- `store.ts` -- Centralized data store. Implements a query-based reactive cache\n  with deduplication and incremental fetching. The `fulfill()` method (called\n  after every render) batches pending queries, computes filter diffs via\n  `unionDiff()`, and fetches only missing data. Connection monitoring polls\n  every 3 seconds for server health, clock skew, and config changes.\n- `components.ts` -- Component registry with a context propagation system. The\n  proxied `m()` function resolves string component names and the `m.context()`\n  API passes data (like the current device object) down the component tree\n  without explicit prop threading.\n- `smart-query.ts` -- Translates user-friendly `Label: value` searches into\n  filter expressions with type-aware matching (string, number, timestamp, MAC\n  address, tag).\n- `task-queue.ts` -- Two-stage task pipeline: staging (user configures tasks)\n  then queue (tasks are committed and executed via the backend).\n- `dynamic-loader.ts` -- Lazy loading of heavy libraries (CodeMirror, YAML) via\n  dynamic `import()`.\n\n### Build System (`build/`)\n\nThe build is a self-bootstrapping esbuild pipeline (`npm run build` pipes\n`build/build.ts` through esbuild then node). It produces:\n\n- **Backend binaries** -- 5 entry points bundled for Node.js 12+ with shebang\n  banners, executable permissions, and `.js` extension stripped.\n- **Frontend bundle** -- `ui/app.ts` bundled for browsers (ESM, code-split) with\n  content-hashed filenames.\n- **CSS** -- `ui/css/app.css` bundled and minified with content-hashed output.\n- **SVG sprite** -- All icons in `ui/icons/` optimized via SVGO and combined\n  into a single sprite.\n\n`build/assets.ts` is a compile-time bridge: at rest it contains placeholder\nfilenames; during build, the `assetsPlugin` replaces them with actual\ncontent-hashed names so both backend and frontend reference the correct assets.\n\nBuild metadata includes the date and a hash derived from the git state, appended\nto the package version.\n\n## Cross-Cutting Concerns\n\n### Multi-Level Caching\n\n```\nmemoize.ts         In-process function cache (2-4 min, two-generation rotation)\n     |\nlocal-cache.ts     In-process snapshot cache (5s refresh, hash-based revisions)\n     |\ncache.ts           MongoDB-backed distributed cache (configurable TTL)\n     |\nMongoDB            TTL index auto-expiration\n```\n\nEach service has its own `local-cache` that periodically checks the distributed\ncache for staleness. Distributed locks (`lock.ts`) coordinate rebuilds so only\none worker does the expensive computation.\n\n### Configuration (`lib/config.ts`)\n\nThree-tier priority: CLI args > environment variables (`GENIEACS_*`) > config\nfile (`config.json`) > defaults. Supports per-device overrides by appending\n`-OUI-ProductClass-SerialNumber` to option names.\n\n### Distributed Locking (`lib/lock.ts`)\n\nMongoDB-based mutual exclusion using upsert + duplicate key detection. TTL\nindexes prevent deadlocks from crashed processes. Clock skew tolerance of 30\nseconds.\n\n### Authentication\n\n- **CWMP devices** -- HTTP Basic or Digest auth, configurable per-device via\n  expressions (`auth.ts`).\n- **UI users** -- JWT tokens in cookies. Passwords hashed with PBKDF2-SHA512\n  (10000 iterations). Role-based authorization via `Authorizer` with\n  expression-based filters and validators.\n\n### Connection Requests (`lib/connection-request.ts`)\n\nThree methods to ask a CPE device to initiate a CWMP session:\n\n- **HTTP** -- Standard TR-069 connection request with Digest/Basic auth.\n- **UDP** -- For NAT traversal (STUN-based) with HMAC-SHA1 signed messages.\n- **XMPP** -- TR-069 Annex K via a full XMPP client (`xmpp-client.ts`).\n\n### Extensions (`lib/extensions.ts`)\n\nUser-defined scripts executed in long-lived child processes (`genieacs-ext.ts`)\ncommunicating via IPC. Processes are lazily spawned per script name and reused.\nEach request gets a unique ID; responses are matched by ID with a configurable\ntimeout.\n\n### Logging (`lib/logger.ts`)\n\nDual-stream structured logging (application + access logs). Supports simple and\nJSON formats, systemd journal integration, and automatic log rotation detection.\nProtocol traces can be written to a debug file in YAML or JSON format\n(`debug.ts`).\n\n### Three-Valued Logic\n\nThe system consistently implements SQL-style three-valued logic\n(true/false/null). This is visible in expression evaluation, the `Clause`\nhierarchy's separate true/false/null methods, and the 2-bit minterm encoding in\nthe Boolean minimizer. NULL means \"unknown\" and propagates through operations\nfollowing SQL semantics.\n\n## Architectural Invariants\n\n- **The expression system does not depend on any service-specific code.** The\n  `lib/common/expression/` modules are pure and shared across all services.\n\n- **The sandbox is deterministic across replays.** `Math.random()` is seeded\n  from the device ID, `Date.now()` is controlled, and extension results are\n  cached. A script re-run with the same inputs produces the same outputs.\n\n- **Services share no in-process state.** All cross-process coordination goes\n  through MongoDB (the `cache` and `locks` collections). Each worker process is\n  independent.\n\n- **Device data is never mutated in place during a session.** `VersionedMap`\n  provides revision-based snapshots. The sandbox writes to new revisions;\n  earlier revisions remain readable for re-evaluation.\n\n- **CWMP session exclusivity.** A distributed lock (`cwmp_session_<deviceId>`)\n  ensures only one session exists per device at a time. The lock is refreshed\n  periodically and released at session end.\n\n- **The UI backend never queries MongoDB with raw user input.** All queries go\n  through the expression-to-MongoDB compiler (`db/synth.ts`), which validates\n  and normalizes expressions before generating filters.\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Change Log\n\n## 1.2.14 (2026-03-12)\n\n- Prevent UI crash when a malformed URL is sent to the server.\n\n- Fix potential edge-case bugs in expression evaluation and session\n  serialization.\n\n## 1.2.13 (2024-06-06)\n\n- Increase connection timeout for UI and NBI from 30 to 120 seconds to avoid\n  timeouts when running unindexed queries in large deployments.\n\n- Fix race condition causing 503 error when deleting multiple faults at once.\n\n- Fix some UI config options not being evaluated as dynamic expressions.\n\n- Fix an issue where certain edge-case query expressions were not being\n  correctly converted to MongoDB queries, resulting in inaccurate search\n  results.\n\n## 1.2.12 (2024-03-28)\n\n- Fix broken XMPP support in the previous release.\n\n- Fix regression causing CSV downloads to be buffered in memory before being\n  streamed to the client.\n\n## 1.2.11 (2024-03-21)\n\n- Resolved an issue from the previous release that caused incompatibility with\n  Node.js versions 12 through 15.\n\n## 1.2.10 (2024-03-18)\n\n- Add support for XMPP connection requests. Use the environment variables\n  `XMPP_JID` and `XMPP_PASSWORD` to configure the XMPP connection for the ACS.\n\n- The environment variables `CWMP_SSL_CERT` and `CWMP_SSL_KEY`, as well as their\n  counterparts for UI, NBI, and FS, now accept PEM-encoded certificates and keys\n  in addition to file paths.\n\n- The UI no longer requires users to refresh the page after modifying presets,\n  provisions, or virtual parameters. Refreshing is now only necessary for\n  changes to users, permissions, or UI configurations.\n\n- Improved conversion of GenieACS expressions into MongoDB queries for more\n  optimized queries and better index utilization.\n\n- Refactor UI pagination and sorting to fix issues from the previous approach,\n  especially with sorting by rapidly changing parameters such as 'last inform'\n  time.\n\n- The file server now supports HEAD requests, the Range header, and conditional\n  requests.\n\n- Addressed an issue causing file server disconnections for slow clients over\n  HTTPS.\n\n- Fix bug preventing users from changing their own passwords.\n\n- Introduced a new 'locks' collection for database locking, replacing the\n  previous use of the 'cache' collection for this purpose.\n\n- Various other minor fixes and enhancements.\n\n## 1.2.9 (2022-08-22)\n\n- New config option `cwmp.skipRootGpn` to enable a workaround for some\n  problematic CPEs that reject GPN requests on data model root.\n- Stream query results and CSV downloads as data becomes available instead of\n  buffering the entire response.\n- Log HTTP/HTTPS client errors in debug log.\n- Fix occasional lock expired errors after updating presets, etc.\n- Fix bug where queries containing `<>` operator may return incorrect results.\n- Fix a performance hit caused by DB calls containing the entire CPE data model\n  rather then just the updated parameters.\n- Fix bug where tags containing special characters are saved in their encoded\n  form when set via a Provision script.\n- Fix issue causing invalid `xsd:dateTime` values to be saved in DB as `NaN`\n  rather than maintain the original string value.\n- Fix bug using `$regex` operator with a numeric or a datetime string.\n- Fix ping not working on certain platforms.\n- Ping requests are now authenticated.\n- Fix error when the `FORWARDED_HEADER` environment variable contains IPv4 CIDR\n  while the listening interface is IPv6 and vice versa.\n- Fix false warning \"unexpected parameter in response\" showing in\n  `genieacs-cwmp` logs.\n- Other minor fixes and stability improvements.\n\n## 1.2.8 (2021-10-27)\n\n- Fix a remote code execution security vulnerability in genieacs-ui.\n- All UI components can now be configured using fully dynamic expressions.\n  Previously some component types only accept fixed string values as properties.\n- The `container` UI component can now be configured with an `element` property\n  that's either a string or a nested pair of `tag` and `attributes` properties.\n  The various attributes under the `attributes` property can now make use of a\n  new function `ENCODEURICOMPONENT()` to facilitate creating custom hyperlinks\n  in the UI.\n- Improve sorting buttons' behavior in the device listing page and other pages.\n  Sorting by multiple columns should now feel more intuitive.\n- Support the modulo (%) operator in expressions.\n- Fix a regression in the previous release where the config option 'ui.pageSize'\n  no longer works.\n- Fix process crash when a CPE sends an unsupported ACS method.\n\n## 1.2.7 (2021-09-18)\n\n- Fix regression causing frequent invalid session errors.\n- Fix regression breaking digest authentication in connection requests.\n\n## 1.2.6 (2021-09-16)\n\n- New config option `cwmp.downloadSuccessOnTimeout` to enable a workaround for\n  CPEs that neglect to send a TransferComplete message after a successful\n  download.\n- Display a progress bar when uploading new files.\n- Default to dual stack interface binding (i.e. `::` instead of `0.0.0.0`).\n  Unless the binding interface is explicitly set, this will cause IPv4 addresses\n  in the logs to be displayed as IPv4-mapped IPv6 addresses (e.g.\n  `::ffff:127.0.0.1` instead of `127.0.0.1`).\n- Detect and correct for client side (browser) clock skew that would otherwise\n  alter the numbers in the pie charts.\n- Various improvements relating to dealing with buggy TR-069 client\n  implementations.\n- Fix a bug causing \"lock expired\" exceptions when a CWMP session remains open\n  for a very long time due to slow clients.\n- Fix metadata of uploaded files going missing due to nginx stripping away what\n  it considers to be invalid headers. The nginx directive\n  `ignore_invalid_headers` is no longer required.\n- Fix crash when a CPE is assigned a tag containing a dot.\n- Fix bug preventing the user from closing the preset pop-up after being\n  presented with unsupported preset message.\n- Fix exceptions raised from ext scripts manifesting as timeout faults.\n- Fix download getting triggered repeatedly when the value passed to `Download`\n  parameter is greater than the current timestamp.\n- Fix crash when passing invalid attributes to `declare`.\n- Fix crash in NBI when pushing tasks to non-existing devices.\n- Fix crash in NBI when passing invalid JSON to various API endpoints.\n- Fix crash when the output from `ping` command cannot be parsed in some rare\n  edge cases.\n- A number of other fixes and stability improvements.\n\n## 1.2.5 (2021-03-12)\n\n- Support specifying custom types when uploading files.\n- Fix JS compatibility issue with Safari browser.\n\n## 1.2.4 (2021-02-24)\n\n- The data model state of a CPE is no longer forgotten after unsuccessful\n  session termination (e.g. timeout). This addresses a number of undesired side\n  effects that arise when a CPE does not terminate the session properly.\n- Executing tasks that take a long time to complete (e.g. refreshing the entire\n  data model) no longer shows a timeout error while the task is still being\n  processed.\n- New function `ROUND()` available to expressions. It works similar to the\n  function by the same name in SQLite and PostgreSQL.\n- Log access events for `genieacs-nbi` service.\n- Pipe stdout/stderr from extension scripts to the `genieacs-cwmp` process log.\n- Parameter values of type `xsd:dateTime` are now displayed in the UI and CSV\n  downloads as a date string rather than a numeric value.\n- Add file download link in the files listing page.\n- Display spinner loading animation throughout the UI.\n- Display GenieACS version and build number underneath the logo.\n- New option to specify how many parameters are displayed at a time in the\n  all-parameters component. Simply set `limit` property in the component config.\n- Reduce overly strong Brotli compression level which was causing significant\n  page load slowdown when Brotli is used.\n- Retire dump-data-model tool. `genieacs-sim` can now use a CSV file as its data\n  model.\n- Reduce the number of concurrent database connections from each process.\n- Remove dependency on 'hostInfo' MongoDB command which is a privileged action.\n  It is now possible to use shared MongoDB instances with limited privileged.\n- Fix bug in NBI where querying files returns 404 error.\n- Fix ping not working for devices with an IPv6 address.\n- Fix an elusive memory leak in `genieacs-fs` that slowly eats up memory and can\n  go unnoticed for long periods of time.\n- Fix a rare edge case where a `declare()` call to set a parameter value may not\n  work as intended if the parameter was originally received as part of the\n  Inform message.\n- A number of other fixes and stability improvements.\n\n## 1.2.3 (2020-10-26)\n\n- New config option 'cwmp.skipWritableCheck' for when some CPEs incorrectly\n  report writable parameters as non-writable. When set to true, the scripts will\n  no longer respect the 'writable' attribute of the CPE parameters and will send\n  a SetParamteerValues, AddObject, or DeleteObject request anyway.\n- Tags no longer restrict what characters are allowed. Any character other than\n  alphanumeric characters, hyphen, or underscore is now encoded in the data\n  model (i.e. Tags.\\<tag>) using its hex value preceded by \"0x\".\n- Ask for a confirmation before closing a pop-up dialog with unsaved changes.\n- Better XML validation to avoid crashes caused by invalid CPE requests.\n- Fix confusing 404 error message when the user attempts to modify a resource\n  when they don't have the necessary permissions.\n- Fix a rare issue where genieacs-cwmp stops accepting new connections after\n  running for a few weeks.\n- Fix exception when IS NULL operator is used in certain situations.\n\n## 1.2.2 (2020-10-03)\n\n- Added button to push files to selected devices from device listing page.\n- A few minor UI improvements.\n- Fix exception that can happen and persist after a Download request.\n- Fix validation bug preventing running refreshObject task on data model root.\n- Fix invalid arguments fault in refresh preset configuration when upgrading\n  from v1.1.\n\n## 1.2.1 (2020-09-08)\n\n- Fix bug causing faults to not be displayed in the UI.\n- Fix bug where deleting objects does not get reflected immediately in the UI.\n- Improve conversion between filters written in the expression format and\n  MongoDB queries. There should now be fewer edge cases where the two are not\n  equisatisfiable.\n\n## 1.2.0 (2020-09-01)\n\n- Support GetParameterAttributes and SetParameterAttributes TR-069 methods.\n- Support CASE statement and COALESCE function in expressions.\n- Provision arguments can now be a list of expressions that are dynamically\n  evaluated.\n- Support Forwarded HTTP header to display in the logs the correct IP of CPEs\n  behind a reverse proxy. Must be configured using FORWARDED_HEADER option.\n- Config expressions can now access all available device parameters, not only\n  serial number, product class, and OUI.\n- Use relative URLs throughout the UI to allow serving from a subdirectory using\n  a reverse proxy.\n- Make Date.parse() and Date.UTC() available to provision scripts.\n- libxmljs has been entirely removed in favor of our bespoke XML parser.\n- Removed the config option CWMP_KEEP_ALIVE_TIMEOUT. SESSION_TIMEOUT is now used\n  to determine the TCP connection timeout.\n- The all-parameters component now limits the number of parameters displayed for\n  better performance.\n- The process genieacs-cwmp is now much less likely to throw exceptions as a\n  result of invalid requests from CPE.\n- A large number of bug fixes and stability improvements.\n\n## 1.2.0-beta.0 (2019-07-30)\n\n- A brand new UI superseding genieacs-gui.\n- New initialization wizard on first run.\n- New expression/query language used in search filters and preset preconditions.\n- CPE -> ACS authentication is now supported.\n- New config option (CWMP_KEEP_ALIVE_TIMEOUT) to specify how long to wait for a\n  reply from the CPE before closing the TCP connection.\n- Debug logging has been reimplemented utilizing YAML format for logs.\n- Handle 9005 faults (Invalid Parameter Name) gracefully by attempting to\n  rediscover the path of the missing parameter recursively.\n- declare() statements not followed by an explicit commit() are now deferred\n  until all currently active scripts have been executed.\n- FS_HOSTNAME now defaults to the server's hostname or IP.\n- The API now validates the structure of task objects before saving.\n- New XML parser implementation for better performance. You can revert to the\n  old parser by enabling the config option XML_LIBXMLJS. Requires Node.js v11 or\n  v10.\n- Performance optimizations. While performance has improved for the majority of\n  use cases, there may be situations where performance has degraded. It's\n  recommended to revisit your hardware requirements.\n- Connection request authentication no longer uses 'auth.js' file. Instead, the\n  connection request authentication behavior can now be customized using an\n  'expression'.\n- The config file (config.json) has been deprecated. System configuration (e.g.\n  listen ports, worker count) are now recommended to be passed as environment.\n  variables. Other general configuration options are stored in the database so\n  as to not require service restart for changes to take effect.\n- Optional redis dependency has been removed completely.\n- Tags now allow only alphanumeric characters and underscore.\n- Supported versions of Node.js and MongoDB are 10.x and up and 2.6 and up\n  respectively.\n\n## 1.1.3 (2018-10-23)\n\n- New config option (MAX_COMMIT_ITERATIONS) to avoid max commit iterations\n  faults for more complex scripts.\n- Support base64 and hexBinary parameter types.\n- Strict parsing of number values in queries (e.g. \"123abc\" no longer accepted\n  as 123).\n- Mixing $ne and $not operators is not allowed. Now it throws an error instead\n  of returning incorrect results.\n- When a task expires, any associated fault is also deleted.\n- API now accepts 'timeout' argument when posting a task.\n- A number of stability fixes.\n\n## 1.1.2 (2018-02-24)\n\n- A large number of bug fixes as well as stability and performance improvements.\n- Three security vulnerabilities disclosed by Maximilian Hils have been patched.\n- New config option UDP_CONNECTION_REQUEST_PORT to specify binding port for UDP\n  connection requests.\n- New config option DATETIME_MILLISECONDS to strip milliseconds from dateTime\n  values.\n- New config option BOOLEAN_LITERAL to use 1/0 or true/false for boolean values.\n- Parameter values that cannot be parsed according to the reported type now show\n  a warning message.\n- Virtual parameter scripts now use the variable 'args' instead of the special\n  'TIMESTAMPS' and 'VALUES' variables. The content of the args array is:\n  {declare timestamps}, {declare values}, {current timestamps}, {current\n  values}.\n- Virtual parameter value types are now inferred from the JavaScript type if the\n  returned value attribute is not a value-type pair.\n- Show a fault when a virtual parameter script doesn't return the required\n  attributes.\n- Redis is now optional (and disabled by default), reducing the complexity of\n  scalable deployments.\n- Better detection of cyclical presets resulting in fewer faults for complex\n  provisioning scripts.\n- Math.random() is now deterministic on per-device basis. A function has been\n  added to allow specifying a seed value (e.g. Math.random.seed(Date.now())).\n- Overload spikes are now handled gracefully by refusing to accept new sessions\n  temporarily when under abnormal load.\n- Added log messages for session timeouts, connection drops, and XML parsing\n  errors.\n- Date.now() now takes an optional argument to specify \"time steps\" (in\n  milliseconds). This can be used to ensure a group of parameters are all\n  refreshed at the same time intervals.\n- Only the non-default configuration options are now logged at process start.\n- Faults caused by errors from extensions now show a cleaner stack trace.\n- Exit main process if there are too many worker crashes (e.g. when DB is down).\n- Updated dependencies and included a lockfile to ensure installations get the\n  exact dependencies it was tested against.\n\n## 1.1.1 (2017-03-23)\n\n- Avoid crashing when connection request credentials are missing.\n- Show a warning instead of crashing when failing to parse parameter values\n  according to the expected value type.\n- Add missing \"Registered\" event.\n- Fix bug where in certain cases many more instances than declared are created.\n- Fix parameter discovery bug when declared path timestamp is 1 or is not set.\n- Fix preset precondition failing when testing against datetime parameters and\n  certain other parameters like \\_deviceId.\\_ProductClass.\n\n## 1.1.0 (2017-03-10)\n\n- Provisions enable implementing dynamic device configuration or complex device\n  provisioning work flow using arbitrary scripts.\n- Virtual parameters are user-defined parameters whose values are evaluated from\n  a custom script.\n- Extensions are sandboxed Node.js scripts that are accessible from provision\n  and virtual parameter scripts to facilitate integration with external\n  entities.\n- Support for UDP/STUN based connection requests for reaching devices behind NAT\n  (TR-069 Annex G).\n- Presets can now be scheduled using a cron-like expression.\n- Presets can now be tied to specific device events (e.g. boot).\n- Presets precondition queries no longer support \"\\$or\" or other MongoDB logical\n  operators.\n- Faults are no longer a part of tasks but are now first class objects.\n- Presets are now assigned to channels. A fault in one channel only blocks\n  presets in that channel.\n- New API CRUD functions for provisions, virtual parameters, and faults.\n- New config options for XML output.\n- API responses now include \"GenieACS-Version\" header.\n- Graceful shutdown when receiving SIGINT and SIGTERM events.\n- Support SSL intermediate certificate chains.\n- Supported Node.js versions are 6.x and 7.x.\n- Supported MongoDB versions are 2.6 through 3.4.\n- Expect performance differences due to major under the hood changes. Some\n  operations are faster and some are slower. Overall performance is improved.\n- GenieACS will no longer fetch the entire device data model upon first contact\n  but will instead only fetch the parameters it needs to fulfill the presets.\n- Logs have been overhauled and split into two streams: process log (stderr) and\n  access log (stdout). Also added config options to dump logs to files rather\n  than standard streams.\n- Connection request authentication credentials are picked up from the device\n  data model if available. config/auth.js is still supported as a fallback and\n  now supports an optional callback argument.\n- Custom commands have been removed. Use virtual parameters and/or extensions.\n- Aliases and value normalizers (config/parameters.json) have been removed. Use\n  virtual parameters.\n- The API /devices/<device_id>/preset has been removed.\n- Rarely used RequestDownload method no longer supported.\n- The TR-069 client simulator has moved to its own repo at\n  https://github.com/zaidka/genieacs-sim\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to GenieACS\n\n## Questions and Support\n\nPlease use the [forum](https://forum.genieacs.com) for questions, help requests,\nand general discussion. GitHub Issues are reserved for confirmed bug reports.\n\n## Issues\n\nWe use GitHub Issues to track bugs. When filing a bug report:\n\n- Provide a clear description of the problem.\n- Include steps to reproduce the issue.\n- Note the GenieACS version, Node.js version, and MongoDB version.\n- Include relevant log output if applicable.\n\nIf you face interoperability issues with a CPE, it is more often than not a\ndevice-specific issue. Please consult the [forum](https://forum.genieacs.com)\nbefore opening an Issue.\n\n## Pull Requests\n\nPull requests are welcome. For bug fixes, go ahead and open a PR directly. For\nnew features or significant changes, please discuss in the\n[forum](https://forum.genieacs.com) first to ensure alignment with the project's\ndirection.\n\nWhen submitting a PR:\n\n- Keep changes focused. One PR should address one concern.\n- Run `npm run lint` and `npm test` before submitting.\n- Update documentation in `docs/` if your change affects user-facing behavior.\n- Add tests where appropriate (see [Testing](#testing)).\n- Write a clear PR description explaining what the change does and why.\n\n## Code Style\n\n### Naming Conventions\n\n- **Variables and functions**: `camelCase`\n- **Classes**: `PascalCase`\n- **Interfaces and type aliases**: `PascalCase`, without an `I` or `T` prefix\n- **Module-level constants**: `UPPER_SNAKE_CASE`\n- **Private class members**: prefixed with underscore (`_name`, `_cache`)\n\nChoose descriptive, meaningful names. A longer clear name is better than a short\nambiguous one.\n\n### Imports\n\nOrganize imports in three groups, in this order:\n\n1. Node.js built-in modules, using the `node:` prefix\n2. External packages\n3. Local project modules\n\nUse `.ts` extensions for all local imports.\n\n```typescript\nimport { readFileSync } from \"node:fs\";\nimport * as http from \"node:http\";\n\nimport { Collection } from \"mongodb\";\n\nimport { connect, disconnect } from \"./db.ts\";\nimport Path from \"./common/path.ts\";\n```\n\n### Functions and Exports\n\nUse the `function` keyword for all named and exported function declarations.\nArrow functions should only be used for callbacks and short inline expressions.\n\n```typescript\n// Named/exported functions use function keyword\nexport function processRequest(req: Request): Response {\n  // ...\n}\n\n// Arrow functions for callbacks\nitems.filter((item) => item.active);\npromise.then((result) => {\n  // ...\n});\n```\n\n### TypeScript\n\n- All function declarations must have explicit return types. This is enforced by\n  ESLint.\n- Using `any` is permitted where full typing would be impractical, but prefer\n  more specific types when reasonable.\n- Prefer `Record<string, T>` over `{ [key: string]: T }` for mapped types.\n- Use type assertions (`as`) sparingly and only when you have stronger type\n  knowledge than the compiler.\n\n### Comments\n\nCode should be self-documenting. Use comments sparingly and only when the \"why\"\nis not obvious from the code itself. When you do comment, use inline `//` style.\n\nDo not use JSDoc (`/** */`) or block comments (`/* */`).\n\n```typescript\n// Good: explains a non-obvious reason\n// Escapes everything except alphanumerics and underscore\nfunction escapeRegExp(str: string): string {\n  /* ... */\n}\n\n// Good: links to an external reference\n// Source: http://stackoverflow.com/a/6969486\n\n// Good: flags a known limitation\n// TODO support \"MD5-sess\" algorithm directive\n\n// Bad: restates what the code does\n// Loop through each item and increment the counter\nfor (const item of items) {\n  counter++;\n}\n```\n\n### Error Handling\n\nUse `== null` (not `=== null || === undefined`) for null/undefined checks.\nESLint is configured to allow this.\n\n## Testing\n\n### When to Write Tests\n\nTests are most valuable for pure logic: parsers, data transformations,\nalgorithms, and utility functions where inputs and outputs are well defined. A\nregression test accompanying a bug fix is also worthwhile to prevent the same\nissue from resurfacing.\n\nNot every change needs a test. Use judgment and consider the likelihood and cost\nof breakage. Avoid writing tests that duplicate coverage already provided by\nexisting tests, and resist the urge to test trivial code just for the sake of\ncoverage numbers.\n\n### Conventions\n\nTests use the Node.js built-in test runner (`node:test`) and assertion module\n(`node:assert`). No external test libraries or mocking frameworks are used.\n\n```typescript\nimport test from \"node:test\";\nimport assert from \"node:assert\";\n\nvoid test(\"parseValue returns correct type for integer strings\", () => {\n  const result = parseValue(\"42\");\n  assert.strictEqual(result, 42);\n});\n\nvoid test(\"parseValue throws on invalid input\", () => {\n  assert.throws(() => parseValue(\"\"), new Error(\"empty value\"));\n});\n```\n\nKey conventions:\n\n- Prefix `test()` calls with `void` to satisfy the `no-floating-promises` lint\n  rule.\n- Keep tests flat. Do not nest `describe` blocks.\n- Use descriptive test names that state what is being tested and the expected\n  outcome.\n- Use `assert.strictEqual()` for value comparisons and\n  `assert.deepStrictEqual()` for objects and arrays.\n\nTest files live in the `test/` directory and are named after the module they\ntest (e.g. `test/path.ts` tests `lib/common/path.ts`).\n\n## Dependencies\n\nThis project deliberately keeps its dependency footprint small. Before adding a\nnew dependency:\n\n- Prefer Node.js built-in APIs when they can do the job.\n- Consider whether the functionality is simple enough to implement directly.\n- Justify the addition in your PR description.\n\nDo not add development tool dependencies (linter plugins, editor integrations,\netc.) without prior discussion.\n\n## File Organization\n\n| Directory     | Contents                                     |\n| ------------- | -------------------------------------------- |\n| `lib/`        | Core server-side application code            |\n| `lib/common/` | Code shared between server and browser       |\n| `lib/db/`     | Database layer (MongoDB)                     |\n| `lib/ui/`     | UI backend helpers                           |\n| `ui/`         | Frontend code (Mithril.js SPA)               |\n| `bin/`        | Service entry points                         |\n| `build/`      | Build tooling                                |\n| `test/`       | Test files                                   |\n| `docs/`       | User documentation (Sphinx/reStructuredText) |\n| `public/`     | Static assets (favicon, logo)                |\n\nPlace new code in the directory that matches its purpose. Server-side logic\nbelongs in `lib/`, code that must run in both Node.js and the browser belongs in\n`lib/common/`, and frontend-only code belongs in `ui/`.\n\n## Documentation\n\nUser documentation lives in the `docs/` directory as reStructuredText files\nbuilt with Sphinx and published to\n[docs.genieacs.com](https://docs.genieacs.com).\n\nWhen your change affects user-facing behavior:\n\n- Update the relevant documentation in `docs/`.\n- If adding a new feature, consider whether it warrants a new page or a section\n  in an existing page.\n- Keep documentation concise and practical. Match the existing tone: direct,\n  factual, no filler.\n\nDocumentation changes should be included in the same PR as the code change, not\nsubmitted separately.\n\n### ARCHITECTURE.md\n\n`ARCHITECTURE.md` describes the high-level architecture of the project: the\nservice boundaries, major subsystems, data flow, and key invariants. It is aimed\nat contributors who need a mental map of the codebase.\n\nThis file has a different update cadence than `docs/`. It should be revisited a\nfew times a year rather than kept in lockstep with every code change. When you\ndo update it, follow these principles:\n\n- **Only describe things that are unlikely to change frequently.** Module\n  responsibilities, service boundaries, key data structures, and architectural\n  invariants belong here. Implementation details, function signatures, and\n  config option lists do not.\n- **Name important files, modules, and types but do not link them.** Links go\n  stale. Encourage the reader to use symbol search to find named entities; this\n  also helps them discover related, similarly named things.\n- **Keep it short.** Every recurring contributor will read it. A shorter\n  document is less likely to become stale and more likely to be maintained.\n- **Describe the \"what\" and \"where\", not the \"how\".** This is a map of the\n  country, not an atlas of its states. Pull detailed explanations into inline\n  code comments or separate documents.\n- **Call out architectural invariants explicitly.** Important invariants are\n  often expressed as the _absence_ of something (e.g., \"the expression system\n  does not depend on service-specific code\") and are hard to discover by reading\n  code alone.\n\n## Git Workflow\n\n### Commit Messages\n\nThis project follows the\n[Conventional Commits](https://www.conventionalcommits.org/) format:\n\n    <type>(<scope>): <subject>\n\n    [optional body]\n\n- Use the imperative mood, present tense: \"Fix bug\", not \"Fixed bug\" or \"Fixes\n  bug\".\n- Do not capitalize the first letter of the subject (the type prefix handles\n  visual structure).\n- Do not end the subject line with a period.\n- Keep the subject line under 72 characters.\n- When more context is helpful, add a body separated from the subject by a blank\n  line, wrapped at 72 characters. The goal is to provide enough information for\n  someone scanning the commit history to find a specific change (e.g. for\n  troubleshooting or rebasing) or to draft changelog entries for a release.\n  Don't be verbose, but don't be cryptic either.\n\n#### Types\n\n| Type       | When to use                                                                 |\n| ---------- | --------------------------------------------------------------------------- |\n| `fix`      | Bug fixes                                                                   |\n| `feat`     | New features or capabilities                                                |\n| `refactor` | Code restructuring with no behavior change                                  |\n| `test`     | Adding or updating tests                                                    |\n| `docs`     | Documentation changes (`docs/`, `CONTRIBUTING.md`, `README.md`)             |\n| `build`    | Build system, dependencies, esbuild config, `package.json` scripts          |\n| `chore`    | Maintenance tasks that don't fit above (`.gitignore`, tooling config, etc.) |\n\n#### Scopes\n\nScope is optional. Use it when it adds useful context; omit it when the change\nis cross-cutting or the subject already makes it obvious.\n\n| Scope  | Covers                              |\n| ------ | ----------------------------------- |\n| `cwmp` | CWMP service (including extensions) |\n| `nbi`  | Northbound REST API service         |\n| `fs`   | File service                        |\n| `ui`   | Web UI (frontend and backend)       |\n| `db`   | Database layer                      |\n\nIf a change touches multiple scopes, either pick the primary one or omit the\nscope entirely.\n\n#### Examples\n\n    feat(nbi): add bulk device delete endpoint\n    fix(cwmp): handle missing ParameterKey in InformResponse\n    refactor(db): replace raw queries with parameterized calls\n    fix(ui): correct parameter table sort order\n    test: add XML parser edge case coverage\n    docs: update provisioning guide for new API\n    build: upgrade esbuild to v0.20\n\n    fix(cwmp): increase server timeout to 2 mins\n\n    To allow enough time for running unindexed queries in large\n    deployments.\n\n### Branches\n\n- `master` is the main development branch.\n- Create a feature or fix branch for your work and open a PR against `master`.\n- Use concise, descriptive branch names in lowercase with hyphens:\n  `fix-race-condition`, `support-xmpp-requests`.\n\n## Changelog\n\nThe changelog (`CHANGELOG.md`) is maintained for each release and is written for\nusers and system administrators, not developers.\n\n- Write entries as clear, user-facing prose. Do not copy commit messages\n  verbatim.\n- Each entry should describe what changed and, when helpful, why it matters.\n- Group entries under a version heading with the release date:\n  `## 1.2.13 (2024-06-06)`.\n- Start each entry with a verb: \"Fix\", \"Add\", \"Improve\", \"Remove\", etc.\n- Include enough context that a user can understand the impact without reading\n  the code.\n\nYou do not need to update the changelog in your PR. The maintainer will add\nchangelog entries during the release process.\n\n## Contributor License Agreement\n\nBy submitting a pull request to this repository, you acknowledge that, while\nmaintaining copyright, you grant GenieACS Inc. a perpetual, worldwide,\nnon-exclusive, no-charge, royalty-free, irrevocable license to reproduce,\nprepare derivative works of, publicly display, publicly perform, sublicense, and\ndistribute your contributions and such derivative works under the AGPLv3 license\nor any other license terms, including, but not limited to, proprietary or\ncommercial license terms.\n\nYou confirm that you own or have rights to distribute and sublicense the source\ncode contained therein, and that your content does not infringe upon the\nintellectual property rights of a third party.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<http://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "# GenieACS\n\n**This is the development branch for GenieACS v1.3. It is unstable and not ready\nfor production use. For the latest stable release, see the\n[v1.2 branch](https://github.com/genieacs/genieacs/tree/v1.2).**\n\nGenieACS is a high performance Auto Configuration Server (ACS) for remote\nmanagement of TR-069 enabled devices. It utilizes a declarative and fault\ntolerant configuration engine for automating complex provisioning scenarios at\nscale. It's battle-tested to handle hundreds of thousands and potentially\nmillions of concurrent devices.\n\n## Quick Start\n\nInstall [Node.js](http://nodejs.org/) and [MongoDB](http://www.mongodb.org/).\nRefer to their corresponding documentation for installation instructions. The\nsupported versions are:\n\n- Node.js: 12.3+\n- MongoDB: 3.6+\n\nInstall GenieACS from NPM:\n\n    sudo npm install -g genieacs\n\nTo build from source instead, clone this repo or download the source archive\nthen _cd_ into the source directory then run:\n\n    npm install\n    npm run build\n\nFinally, run the following services (found under `./dist/bin/` if building from\nsource):\n\n### genieacs-cwmp\n\nThis is the service that the CPEs will communicate with. It listens on port 7547\nby default. Configure the ACS URL in your devices accordingly.\n\nYou may optionally use [genieacs-sim](https://github.com/genieacs/genieacs-sim)\nas a dummy TR-069 simulator if you don't have a CPE at hand.\n\n### genieacs-nbi\n\nThis is the northbound interface module. It exposes a REST API on port 7557 by\ndefault. This one is only required if you have an external system integrating\nwith GenieACS using this API.\n\n### genieacs-fs\n\nThis is the file server from which the CPEs will download firmware images and\nsuch. It listens on port 7567 by default.\n\n### genieacs-ui\n\nThis serves the web based user interface. It listens on port 3000 by default.\nYou must pass _--ui-jwt-secret_ argument to supply the secret key used for\nsigning browser cookies:\n\n    genieacs-ui --ui-jwt-secret secret\n\nThe UI has plenty of configuration options. When you open GenieACS's UI in a\nbrowser you'll be greeted with a database initialization wizard to help you\npopulate some initial configuration.\n\nVisit [docs.genieacs.com](https://docs.genieacs.com) for more documentation and\na complete installation guide for production deployments.\n\n## Support\n\nThe [forum](https://forum.genieacs.com) is a good place to get guidance and help\nfrom the community. Head on over and join the conversation!\n\nFor commercial support options, please visit\n[genieacs.com](https://genieacs.com/support/).\n\n## License\n\nCopyright 2013-2026 GenieACS Inc. GenieACS is released under the\n[AGPLv3 license terms](https://raw.githubusercontent.com/genieacs/genieacs/master/LICENSE).\n"
  },
  {
    "path": "bin/genieacs-cwmp.ts",
    "content": "import * as config from \"../lib/config.ts\";\nimport * as logger from \"../lib/logger.ts\";\nimport * as cluster from \"../lib/cluster.ts\";\nimport * as server from \"../lib/server.ts\";\nimport * as cwmp from \"../lib/cwmp.ts\";\nimport * as db from \"../lib/db/db.ts\";\nimport * as extensions from \"../lib/extensions.ts\";\nimport { version as VERSION } from \"../package.json\";\n\nlogger.init(\"cwmp\", VERSION);\n\nconst SERVICE_ADDRESS = config.get(\"CWMP_INTERFACE\") as string;\nconst SERVICE_PORT = config.get(\"CWMP_PORT\") as number;\n\nfunction exitWorkerGracefully(): void {\n  setTimeout(exitWorkerUngracefully, 5000).unref();\n  Promise.all([\n    db.disconnect(),\n    extensions.killAll(),\n    cluster.worker.disconnect(),\n  ])\n    .then(logger.close)\n    .catch(exitWorkerUngracefully);\n}\n\nfunction exitWorkerUngracefully(): void {\n  void extensions.killAll().finally(() => {\n    process.exit(1);\n  });\n}\n\nif (!cluster.worker) {\n  const WORKER_COUNT = config.get(\"CWMP_WORKER_PROCESSES\") as number;\n\n  logger.info({\n    message: `genieacs-cwmp starting`,\n    pid: process.pid,\n    version: VERSION,\n  });\n\n  cluster.start(WORKER_COUNT, SERVICE_PORT, SERVICE_ADDRESS);\n\n  process.on(\"SIGINT\", () => {\n    logger.info({\n      message: \"Received signal SIGINT, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n\n  process.on(\"SIGTERM\", () => {\n    logger.info({\n      message: \"Received signal SIGTERM, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n} else {\n  const key = config.get(\"CWMP_SSL_KEY\") as string;\n  const cert = config.get(\"CWMP_SSL_CERT\") as string;\n\n  const options = {\n    port: SERVICE_PORT,\n    host: SERVICE_ADDRESS,\n    ssl: key && cert ? { key, cert } : null,\n    onConnection: cwmp.onConnection,\n    onClientError: cwmp.onClientError,\n    timeout: 30000,\n    keepAliveTimeout: 0,\n    requestTimeout: cwmp.REQUEST_TIMEOUT,\n  };\n\n  // Need this for Node < 15\n  process.on(\"unhandledRejection\", (err) => {\n    throw err;\n  });\n\n  process.on(\"uncaughtException\", (err) => {\n    if ((err as NodeJS.ErrnoException).code === \"ERR_IPC_DISCONNECTED\") return;\n    logger.error({\n      message: \"Uncaught exception\",\n      exception: err,\n      pid: process.pid,\n    });\n    server.stop(false).then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n  });\n\n  const initPromise = db\n    .connect()\n    .then(() => {\n      server.start(options, cwmp.listener);\n    })\n    .catch((err) => {\n      setTimeout(() => {\n        throw err;\n      });\n    });\n\n  process.on(\"SIGINT\", () => {\n    void initPromise.finally(() => {\n      server\n        .stop(false)\n        .then(exitWorkerGracefully)\n        .catch(exitWorkerUngracefully);\n    });\n  });\n\n  process.on(\"SIGTERM\", () => {\n    void initPromise.finally(() => {\n      server\n        .stop(false)\n        .then(exitWorkerGracefully)\n        .catch(exitWorkerUngracefully);\n    });\n  });\n}\n"
  },
  {
    "path": "bin/genieacs-ext.ts",
    "content": "import { Fault } from \"../lib/types.ts\";\n\nconst jobs = new Set();\nconst fileName = process.argv[2];\nlet script;\n\nfunction errorToFault(err: Error): Fault {\n  if (!err) return null;\n\n  if (!err.name) return { code: \"ext\", message: `${err}` };\n\n  const fault: Fault = {\n    code: `ext.${err.name}`,\n    message: err.message,\n    detail: {\n      name: err.name,\n      message: err.message,\n    },\n  };\n\n  if (err.stack) {\n    fault.detail[\"stack\"] = err.stack;\n    // Trim the stack trace\n    const stackTrimIndex = fault.detail[\"stack\"].match(\n      /\\s+at\\s[^\\s]+\\s\\(.*genieacs-ext:.+\\)/,\n    );\n    if (stackTrimIndex) {\n      fault.detail[\"stack\"] = fault.detail[\"stack\"].slice(\n        0,\n        stackTrimIndex.index,\n      );\n    }\n  }\n\n  return fault;\n}\n\n// Need this for Node < 15\nprocess.on(\"unhandledRejection\", (err) => {\n  throw err;\n});\n\nprocess.on(\"uncaughtException\", (err) => {\n  const fault = errorToFault(err);\n  jobs.forEach((jobId) => {\n    process.send([jobId, fault, null]);\n  });\n  jobs.clear();\n  process.disconnect();\n});\n\nprocess.on(\"message\", (message) => {\n  jobs.add(message[0]);\n\n  if (!script) {\n    const cwd = process.env[\"GENIEACS_EXT_DIR\"];\n    process.chdir(cwd);\n    // eslint-disable-next-line @typescript-eslint/no-require-imports\n    script = require(`${cwd}/${fileName}`);\n  }\n\n  const funcName = message[1][0];\n\n  if (!script[funcName]) {\n    const fault = {\n      code: \"ext\",\n      message: `No such function '${funcName}' in extension '${fileName}'`,\n    };\n    process.send([message[0], fault, null]);\n    return;\n  }\n\n  script[funcName](message[1].slice(1), (err, res) => {\n    if (!jobs.delete(message[0])) return;\n\n    process.send([message[0], errorToFault(err), res]);\n  });\n});\n\n// Ignore SIGINT\nprocess.on(\"SIGINT\", () => {\n  // Ignore\n});\n"
  },
  {
    "path": "bin/genieacs-fs.ts",
    "content": "import * as config from \"../lib/config.ts\";\nimport * as logger from \"../lib/logger.ts\";\nimport * as cluster from \"../lib/cluster.ts\";\nimport * as server from \"../lib/server.ts\";\nimport { listener } from \"../lib/fs.ts\";\nimport * as db from \"../lib/db/db.ts\";\nimport { version as VERSION } from \"../package.json\";\n\nlogger.init(\"fs\", VERSION);\n\nconst SERVICE_ADDRESS = config.get(\"FS_INTERFACE\") as string;\nconst SERVICE_PORT = config.get(\"FS_PORT\") as number;\n\nfunction exitWorkerGracefully(): void {\n  setTimeout(exitWorkerUngracefully, 5000).unref();\n  Promise.all([db.disconnect(), cluster.worker.disconnect()])\n    .then(logger.close)\n    .catch(exitWorkerUngracefully);\n}\n\nfunction exitWorkerUngracefully(): void {\n  process.exit(1);\n}\n\nif (!cluster.worker) {\n  const WORKER_COUNT = config.get(\"FS_WORKER_PROCESSES\") as number;\n\n  logger.info({\n    message: `genieacs-fs starting`,\n    pid: process.pid,\n    version: VERSION,\n  });\n\n  cluster.start(WORKER_COUNT, SERVICE_PORT, SERVICE_ADDRESS);\n\n  process.on(\"SIGINT\", () => {\n    logger.info({\n      message: \"Received signal SIGINT, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n\n  process.on(\"SIGTERM\", () => {\n    logger.info({\n      message: \"Received signal SIGTERM, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n} else {\n  const key = config.get(\"FS_SSL_KEY\") as string;\n  const cert = config.get(\"FS_SSL_CERT\") as string;\n  const options = {\n    port: SERVICE_PORT,\n    host: SERVICE_ADDRESS,\n    ssl: key && cert ? { key, cert } : null,\n    timeout: 30000,\n  };\n\n  // Need this for Node < 15\n  process.on(\"unhandledRejection\", (err) => {\n    throw err;\n  });\n\n  process.on(\"uncaughtException\", (err) => {\n    if ((err as NodeJS.ErrnoException).code === \"ERR_IPC_DISCONNECTED\") return;\n    logger.error({\n      message: \"Uncaught exception\",\n      exception: err,\n      pid: process.pid,\n    });\n    server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n  });\n\n  const initPromise = db\n    .connect()\n    .then(() => {\n      server.start(options, listener);\n    })\n    .catch((err) => {\n      setTimeout(() => {\n        throw err;\n      });\n    });\n\n  process.on(\"SIGINT\", () => {\n    void initPromise.finally(() => {\n      server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n    });\n  });\n\n  process.on(\"SIGTERM\", () => {\n    void initPromise.finally(() => {\n      server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n    });\n  });\n}\n"
  },
  {
    "path": "bin/genieacs-nbi.ts",
    "content": "import * as config from \"../lib/config.ts\";\nimport * as logger from \"../lib/logger.ts\";\nimport * as cluster from \"../lib/cluster.ts\";\nimport * as server from \"../lib/server.ts\";\nimport { listener } from \"../lib/nbi.ts\";\nimport * as db from \"../lib/db/db.ts\";\nimport * as extensions from \"../lib/extensions.ts\";\nimport { version as VERSION } from \"../package.json\";\n\nlogger.init(\"nbi\", VERSION);\n\nconst SERVICE_ADDRESS = config.get(\"NBI_INTERFACE\") as string;\nconst SERVICE_PORT = config.get(\"NBI_PORT\") as number;\n\nfunction exitWorkerGracefully(): void {\n  setTimeout(exitWorkerUngracefully, 5000).unref();\n  Promise.all([\n    db.disconnect(),\n    extensions.killAll(),\n    cluster.worker.disconnect(),\n  ])\n    .then(logger.close)\n    .catch(exitWorkerUngracefully);\n}\n\nfunction exitWorkerUngracefully(): void {\n  void extensions.killAll().finally(() => {\n    process.exit(1);\n  });\n}\n\nif (!cluster.worker) {\n  const WORKER_COUNT = config.get(\"NBI_WORKER_PROCESSES\") as number;\n\n  logger.info({\n    message: `genieacs-nbi starting`,\n    pid: process.pid,\n    version: VERSION,\n  });\n\n  cluster.start(WORKER_COUNT, SERVICE_PORT, SERVICE_ADDRESS);\n\n  process.on(\"SIGINT\", () => {\n    logger.info({\n      message: \"Received signal SIGINT, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n\n  process.on(\"SIGTERM\", () => {\n    logger.info({\n      message: \"Received signal SIGTERM, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n} else {\n  const key = config.get(\"NBI_SSL_KEY\") as string;\n  const cert = config.get(\"NBI_SSL_CERT\") as string;\n  const options = {\n    port: SERVICE_PORT,\n    host: SERVICE_ADDRESS,\n    ssl: key && cert ? { key, cert } : null,\n    timeout: 120000,\n  };\n\n  // Need this for Node < 15\n  process.on(\"unhandledRejection\", (err) => {\n    throw err;\n  });\n\n  process.on(\"uncaughtException\", (err) => {\n    if ((err as NodeJS.ErrnoException).code === \"ERR_IPC_DISCONNECTED\") return;\n    logger.error({\n      message: \"Uncaught exception\",\n      exception: err,\n      pid: process.pid,\n    });\n    server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n  });\n\n  const initPromise = db\n    .connect()\n    .then(() => {\n      server.start(options, listener);\n    })\n    .catch((err) => {\n      setTimeout(() => {\n        throw err;\n      });\n    });\n\n  process.on(\"SIGINT\", () => {\n    void initPromise.finally(() => {\n      server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n    });\n  });\n\n  process.on(\"SIGTERM\", () => {\n    void initPromise.finally(() => {\n      server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n    });\n  });\n}\n"
  },
  {
    "path": "bin/genieacs-ui.ts",
    "content": "import * as config from \"../lib/config.ts\";\nimport * as logger from \"../lib/logger.ts\";\nimport * as cluster from \"../lib/cluster.ts\";\nimport * as server from \"../lib/server.ts\";\nimport { listener } from \"../lib/ui.ts\";\nimport * as extensions from \"../lib/extensions.ts\";\nimport * as db from \"../lib/db/db.ts\";\nimport { version as VERSION } from \"../package.json\";\n\nlogger.init(\"ui\", VERSION);\n\nconst SERVICE_ADDRESS = config.get(\"UI_INTERFACE\") as string;\nconst SERVICE_PORT = config.get(\"UI_PORT\") as number;\n\nfunction exitWorkerGracefully(): void {\n  setTimeout(exitWorkerUngracefully, 5000).unref();\n  Promise.all([\n    db.disconnect(),\n    extensions.killAll(),\n    cluster.worker.disconnect(),\n  ])\n    .then(logger.close)\n    .catch(exitWorkerUngracefully);\n}\n\nfunction exitWorkerUngracefully(): void {\n  void extensions.killAll().finally(() => {\n    process.exit(1);\n  });\n}\n\nif (!cluster.worker) {\n  const WORKER_COUNT = config.get(\"UI_WORKER_PROCESSES\") as number;\n\n  logger.info({\n    message: `genieacs-ui starting`,\n    pid: process.pid,\n    version: VERSION,\n  });\n\n  cluster.start(WORKER_COUNT, SERVICE_PORT, SERVICE_ADDRESS);\n\n  process.on(\"SIGINT\", () => {\n    logger.info({\n      message: \"Received signal SIGINT, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n\n  process.on(\"SIGTERM\", () => {\n    logger.info({\n      message: \"Received signal SIGTERM, exiting\",\n      pid: process.pid,\n    });\n\n    cluster.stop();\n  });\n} else {\n  const key = config.get(\"UI_SSL_KEY\") as string;\n  const cert = config.get(\"UI_SSL_CERT\") as string;\n  const options = {\n    port: SERVICE_PORT,\n    host: SERVICE_ADDRESS,\n    ssl: key && cert ? { key, cert } : null,\n    timeout: 120000,\n  };\n\n  // Need this for Node < 15\n  process.on(\"unhandledRejection\", (err) => {\n    throw err;\n  });\n\n  process.on(\"uncaughtException\", (err) => {\n    if ((err as NodeJS.ErrnoException).code === \"ERR_IPC_DISCONNECTED\") return;\n    logger.error({\n      message: \"Uncaught exception\",\n      exception: err,\n      pid: process.pid,\n    });\n    server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n  });\n\n  const initPromise = db\n    .connect()\n    .then(() => {\n      server.start(options, async (req, res) => listener(req, res));\n    })\n    .catch((err) => {\n      setTimeout(() => {\n        throw err;\n      });\n    });\n\n  process.on(\"SIGINT\", () => {\n    void initPromise.finally(() => {\n      server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n    });\n  });\n\n  process.on(\"SIGTERM\", () => {\n    void initPromise.finally(() => {\n      server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);\n    });\n  });\n}\n"
  },
  {
    "path": "build/assets.ts",
    "content": "export const APP_JS = \"app.js\";\nexport const APP_CSS = \"app.css\";\nexport const ICONS_SVG = \"icons.svg\";\nexport const LOGO_SVG = \"logo.svg\";\nexport const FAVICON_PNG = \"favicon.png\";\n"
  },
  {
    "path": "build/build.ts",
    "content": "import path from \"node:path\";\nimport fs from \"node:fs\";\nimport { createHash } from \"node:crypto\";\nimport { promisify } from \"node:util\";\nimport { exec } from \"node:child_process\";\nimport * as esbuild from \"esbuild\";\nimport { optimize } from \"svgo\";\nimport * as xmlParser from \"../lib/xml-parser.ts\";\n\nconst fsAsync = {\n  readdir: promisify(fs.readdir),\n  readFile: promisify(fs.readFile),\n  writeFile: promisify(fs.writeFile),\n  copyFile: promisify(fs.copyFile),\n  rename: promisify(fs.rename),\n  chmod: promisify(fs.chmod),\n  lstat: promisify(fs.lstat),\n  exists: promisify(fs.exists),\n  rmdir: promisify(fs.rmdir),\n  unlink: promisify(fs.unlink),\n  mkdir: promisify(fs.mkdir),\n};\n\nconst execAsync = promisify(exec);\n\nconst MODE = process.env[\"NODE_ENV\"] || \"production\";\n\nconst INPUT_DIR = process.cwd();\nconst OUTPUT_DIR = path.join(INPUT_DIR, \"dist\");\n\nasync function rmDir(dirPath: string): Promise<void> {\n  if (!(await fsAsync.exists(dirPath))) return;\n  const files = await fsAsync.readdir(dirPath);\n\n  for (const file of files) {\n    const filePath = path.join(dirPath, file);\n    if ((await fsAsync.lstat(filePath)).isDirectory()) await rmDir(filePath);\n    else await fsAsync.unlink(filePath);\n  }\n  await fsAsync.rmdir(dirPath);\n}\n\n// For lockfileVersion = 1\nfunction stripDevDeps(deps): void {\n  if (!deps[\"dependencies\"]) return;\n  for (const [k, v] of Object.entries(deps[\"dependencies\"])) {\n    if (v[\"dev\"]) delete deps[\"dependencies\"][k];\n    else stripDevDeps(v);\n  }\n  if (!Object.keys(deps[\"dependencies\"]).length) delete deps[\"dependencies\"];\n}\n\n// For lockfileVersion = 2\nfunction stripDevDeps2(deps): void {\n  if (!deps[\"packages\"]) return;\n  for (const [k, v] of Object.entries(deps[\"packages\"])) {\n    delete v[\"devDependencies\"];\n    if (v[\"dev\"]) delete deps[\"packages\"][k];\n  }\n}\n\nfunction xmlTostring(xml): string {\n  const children = [];\n  for (const c of xml.children || []) children.push(xmlTostring(c));\n\n  return xml.name === \"root\" && xml.bodyIndex === 0\n    ? children.join(\"\")\n    : `<${xml.name} ${xml.attrs}>${children.join(\"\")}</${xml.name}>`;\n}\n\nfunction assetHash(buffer: Buffer | string): string {\n  return createHash(\"md5\").update(buffer).digest(\"hex\").slice(0, 8);\n}\n\nconst ASSETS = {} as {\n  APP_JS?: string;\n  APP_CSS?: string;\n  ICONS_SVG?: string;\n  LOGO_SVG?: string;\n  FAVICON_PNG?: string;\n};\n\nconst assetsPlugin = {\n  name: \"assets\",\n  setup(build) {\n    build.onLoad({ filter: /\\/build\\/assets.ts$/ }, () => {\n      const lines = Object.entries(ASSETS).map(\n        ([k, v]) => `export const ${k} = ${JSON.stringify(v)};`,\n      );\n      return { contents: lines.join(\"\\n\") };\n    });\n  },\n} as esbuild.Plugin;\n\nconst seedPlugin = {\n  name: \"seed\",\n  setup(build) {\n    build.onLoad({ filter: /\\/seed\\// }, (args) => {\n      if (args.with?.[\"type\"] !== \"text\") return undefined;\n      let contents = fs.readFileSync(args.path, \"utf8\");\n      // Strip TypeScript directives that are only needed for type-checking\n      contents = contents.replace(/^\\s*\\/\\/\\s*@ts-.*\\n/gm, \"\\n\");\n      return { contents, loader: \"text\" };\n    });\n  },\n} as esbuild.Plugin;\n\nconst packageDotJsonPlugin = {\n  name: \"packageDotJson\",\n  setup(build) {\n    const sourcePath = path.join(INPUT_DIR, \"package.json\");\n    build.onResolve({ filter: /\\/package.json$/ }, (args) => {\n      const p = path.join(args.resolveDir, args.path);\n      if (p !== sourcePath) return undefined;\n      return { path: path.join(OUTPUT_DIR, \"package.json\") };\n    });\n  },\n} as esbuild.Plugin;\n\nconst inlineDepsPlugin = {\n  name: \"inlineDeps\",\n  setup(build) {\n    const deps = [\"espresso-iisojs\", \"@codemirror\", \"mithril\", \"yaml\"];\n    const depFiles = new Set();\n    build.onResolve({ filter: /^[^.]/ }, async (args) => {\n      if (args.pluginData === \"inlineDeps\") return undefined;\n      if (\n        depFiles.has(args.importer) ||\n        deps.some((d) => args.path.startsWith(d))\n      ) {\n        const res = await build.resolve(args.path, {\n          importer: args.importer,\n          namespace: args.namespace,\n          resolveDir: args.resolveDir,\n          kind: args.kind,\n          with: args.with,\n          pluginData: \"inlineDeps\",\n        });\n        depFiles.add(res.path);\n        return res;\n      }\n      return { sideEffects: false, external: true };\n    });\n  },\n} as esbuild.Plugin;\n\nfunction generateSymbol(id: string, svgStr: string): string {\n  const xml = xmlParser.parseXml(svgStr);\n  const svg = xml.children[0];\n  const svgAttrs = xmlParser.parseAttrs(svg.attrs);\n  let viewBox = \"\";\n  for (const a of svgAttrs) {\n    if (a.name === \"viewBox\") {\n      viewBox = `viewBox=\"${a.value}\"`;\n      break;\n    }\n  }\n  const symbolBody = xml.children[0].children\n    .map((c) => xmlTostring(c))\n    .join(\"\");\n  return `<symbol id=\"icon-${id}\" ${viewBox}>${symbolBody}</symbol>`;\n}\n\nasync function getBuildMetadata(): Promise<string> {\n  const date = new Date().toISOString().slice(2, 10).replaceAll(\"-\", \"\");\n\n  const [commit, diff, newFiles] = await Promise.all([\n    execAsync(\"git rev-parse HEAD\"),\n    execAsync(\"git diff HEAD\"),\n    execAsync(\"git ls-files --others --exclude-standard\"),\n  ]).then((res) => res.map((r) => r.stdout.trim()));\n\n  if (!diff && !newFiles) return date + commit.slice(0, 4);\n\n  const hash = createHash(\"md5\");\n  hash.update(commit).update(diff).update(newFiles);\n  for (const file of newFiles.split(\"\\n\").filter((f) => f))\n    hash.update(await fsAsync.readFile(file));\n  return date + hash.digest(\"hex\").slice(0, 4);\n}\n\nasync function init(): Promise<void> {\n  const [buildMetadata, packageJsonFile, npmShrinkwrapFile] = await Promise.all(\n    [\n      getBuildMetadata(),\n      fsAsync.readFile(path.join(INPUT_DIR, \"package.json\")),\n      fsAsync.readFile(path.join(INPUT_DIR, \"npm-shrinkwrap.json\")),\n    ],\n  );\n\n  const packageJson = JSON.parse(packageJsonFile.toString());\n  delete packageJson[\"devDependencies\"];\n  delete packageJson[\"private\"];\n  delete packageJson[\"scripts\"];\n  packageJson[\"version\"] = `${packageJson[\"version\"]}+${buildMetadata}`;\n\n  const npmShrinkwrap = JSON.parse(npmShrinkwrapFile.toString());\n  npmShrinkwrap[\"version\"] = packageJson[\"version\"];\n  stripDevDeps(npmShrinkwrap);\n  stripDevDeps2(npmShrinkwrap);\n\n  await rmDir(OUTPUT_DIR);\n\n  await fsAsync.mkdir(OUTPUT_DIR);\n\n  await Promise.all([\n    fsAsync.mkdir(path.join(OUTPUT_DIR, \"bin\")),\n    fsAsync.mkdir(path.join(OUTPUT_DIR, \"public\")),\n    fsAsync.writeFile(\n      path.join(OUTPUT_DIR, \"package.json\"),\n      JSON.stringify(packageJson, null, 2),\n    ),\n    fsAsync.writeFile(\n      path.join(OUTPUT_DIR, \"npm-shrinkwrap.json\"),\n      JSON.stringify(npmShrinkwrap, null, 2),\n    ),\n  ]);\n}\n\nasync function copyStatic(): Promise<void> {\n  const files = [\n    \"LICENSE\",\n    \"README.md\",\n    \"CHANGELOG.md\",\n    \"public/logo.svg\",\n    \"public/favicon.png\",\n  ];\n\n  const [logo, favicon] = await Promise.all([\n    fsAsync.readFile(path.join(INPUT_DIR, \"public/logo.svg\")),\n    fsAsync.readFile(path.join(INPUT_DIR, \"public/favicon.png\")),\n  ]);\n\n  ASSETS.LOGO_SVG = `logo-${assetHash(logo)}.svg`;\n  ASSETS.FAVICON_PNG = `favicon-${assetHash(favicon)}.png`;\n\n  const filenames = {} as Record<string, string>;\n  filenames[\"public/logo.svg\"] = path.join(\"public\", ASSETS.LOGO_SVG);\n  filenames[\"public/favicon.png\"] = path.join(\"public\", ASSETS.FAVICON_PNG);\n\n  await Promise.all(\n    files.map((f) =>\n      fsAsync.copyFile(\n        path.join(INPUT_DIR, f),\n        path.join(OUTPUT_DIR, filenames[f] || f),\n      ),\n    ),\n  );\n}\n\nasync function generateCss(): Promise<void> {\n  const tailwindPlugin = {\n    name: \"tailwind\",\n    setup(build) {\n      build.onLoad({ filter: /\\/ui\\/css\\/app.css$/ }, async (args) => {\n        const res = await execAsync(`npx @tailwindcss/cli -i ${args.path}`);\n        return { loader: \"css\", contents: res.stdout };\n      });\n    },\n  } as esbuild.Plugin;\n\n  const res = await esbuild.build({\n    bundle: true,\n    absWorkingDir: INPUT_DIR,\n    minify: MODE === \"production\",\n    sourcemap: \"linked\",\n    sourcesContent: false,\n    entryPoints: [\"ui/css/app.css\"],\n    entryNames: \"[dir]/[name]-[hash]\",\n    outfile: path.join(OUTPUT_DIR, \"public/app.css\"),\n    plugins: [tailwindPlugin],\n    loader: {\n      \".woff2\": \"dataurl\",\n    },\n    target: [\"chrome111\", \"safari16.4\", \"firefox128\"],\n    metafile: true,\n  });\n\n  for (const [k, v] of Object.entries(res.metafile.outputs)) {\n    if (v.entryPoint === \"ui/css/app.css\") {\n      ASSETS.APP_CSS = path.relative(\n        path.join(OUTPUT_DIR, \"public\"),\n        path.join(INPUT_DIR, k),\n      );\n      break;\n    }\n  }\n}\n\nasync function generateBackendJs(): Promise<void> {\n  const services = [\n    \"genieacs-cwmp\",\n    \"genieacs-ext\",\n    \"genieacs-nbi\",\n    \"genieacs-fs\",\n    \"genieacs-ui\",\n  ];\n\n  await esbuild.build({\n    bundle: true,\n    absWorkingDir: INPUT_DIR,\n    minify: MODE === \"production\",\n    define: {\n      \"process.env.NODE_ENV\": JSON.stringify(MODE),\n    },\n    sourcemap: \"inline\",\n    sourcesContent: false,\n    platform: \"node\",\n    target: \"node12.13.0\",\n    packages: \"external\",\n    banner: { js: \"#!/usr/bin/env node\" },\n    entryPoints: services.map((s) => `bin/${s}.ts`),\n    outdir: path.join(OUTPUT_DIR, \"bin\"),\n    plugins: [packageDotJsonPlugin, assetsPlugin, seedPlugin],\n  });\n\n  for (const bin of services) {\n    const p = path.join(OUTPUT_DIR, \"bin\", bin);\n    await fsAsync.rename(`${p}.js`, p);\n    // Mark as executable\n    const mode = (await fsAsync.lstat(p)).mode;\n    await fsAsync.chmod(p, mode | 73);\n  }\n}\n\nasync function generateFrontendJs(): Promise<void> {\n  const res = await esbuild.build({\n    bundle: true,\n    absWorkingDir: INPUT_DIR,\n    splitting: true,\n    minify: MODE === \"production\",\n    sourcemap: \"linked\",\n    sourcesContent: false,\n    platform: \"browser\",\n    format: \"esm\",\n    target: [\"chrome111\", \"safari16.4\", \"firefox128\"],\n    entryPoints: [\"ui/app.ts\"],\n    entryNames: \"[dir]/[name]-[hash]\",\n    outdir: path.join(OUTPUT_DIR, \"public\"),\n    plugins: [packageDotJsonPlugin, inlineDepsPlugin, assetsPlugin],\n    metafile: true,\n  });\n\n  for (const [k, v] of Object.entries(res.metafile.outputs)) {\n    for (const imp of v.imports)\n      if (imp.external && imp.path !== \"views-bundle\")\n        throw new Error(`External import found: ${imp.path}`);\n\n    if (v.entryPoint === \"ui/app.ts\") {\n      ASSETS.APP_JS = path.relative(\n        path.join(OUTPUT_DIR, \"public\"),\n        path.join(INPUT_DIR, k),\n      );\n    }\n  }\n}\n\nasync function generateIconsSprite(): Promise<void> {\n  const symbols = [] as string[];\n  const iconsDir = path.join(INPUT_DIR, \"ui/icons\");\n  for (const file of await fsAsync.readdir(iconsDir)) {\n    const id = path.parse(file).name;\n    const filePath = path.join(iconsDir, file);\n    const src = (await fsAsync.readFile(filePath)).toString();\n    const { data } = await optimize(src, {\n      plugins: [\n        {\n          name: \"preset-default\",\n          params: {\n            overrides: {\n              removeViewBox: false,\n            },\n          },\n        },\n      ],\n    });\n    symbols.push(generateSymbol(id, data));\n  }\n  const data = `<svg xmlns=\"http://www.w3.org/2000/svg\">${symbols.join(\n    \"\",\n  )}</svg>`;\n  ASSETS.ICONS_SVG = `icons-${assetHash(data)}.svg`;\n  await fsAsync.writeFile(\n    path.join(OUTPUT_DIR, \"public\", ASSETS.ICONS_SVG),\n    data,\n  );\n}\n\ninit()\n  .then(() =>\n    Promise.all([\n      Promise.all([generateIconsSprite(), copyStatic()]).then(\n        generateFrontendJs,\n      ),\n      generateCss(),\n    ]).then(generateBackendJs),\n  )\n  .catch((err) => {\n    process.stderr.write(err.stack + \"\\n\");\n  });\n"
  },
  {
    "path": "build/generate-fonts.sh",
    "content": "#!/bin/bash\n\ncd \"$(dirname \"$0\")\"\n\ndeclare -A FONTS=(\n  [InterVariable]=https://rsms.me/inter/font-files/InterVariable.woff2\n  [InterVariable-Italic]=https://rsms.me/inter/font-files/InterVariable-Italic.woff2\n  [RobotoMono]=https://raw.githubusercontent.com/googlefonts/RobotoMono/main/fonts/variable/RobotoMono%5Bwght%5D.ttf\n)\n\nfor name in \"${!FONTS[@]}\"\ndo\n  url=\"${FONTS[$name]}\"\n  echo \"Downloading $url\"\n  TMP=$(mktemp)\n  curl \"$url\" -s --output \"$TMP\"\n  pyftsubset \"$TMP\" --output-file=\"../ui/css/$name.woff2\" --flavor=woff2 --unicodes=\"U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD\" --layout-features+=\"tnum\"\ndone\n"
  },
  {
    "path": "build/lint.ts",
    "content": "import { exec } from \"node:child_process\";\nimport { promisify } from \"node:util\";\n\nconst execPromise = promisify(exec);\n\nasync function runEslint(): Promise<string> {\n  const CMD =\n    \"eslint 'bin/*.ts' 'lib/**/*.ts' 'ui/**/*.ts' 'test/**/*.ts' 'build/**/*.ts' 'seed/*'\";\n  const env = {\n    ...(process.stdout.isTTY && { FORCE_COLOR: \"1\" }),\n    ...process.env,\n  };\n  try {\n    const { stdout, stderr } = await execPromise(CMD, { env });\n    if (stderr) throw new Error(stderr);\n    return stdout;\n  } catch (err) {\n    if (err.killed || err.signal || err.stderr || err.code !== 1) throw err;\n    return err.stdout;\n  }\n}\n\nasync function runTsc(): Promise<string> {\n  const CMD = \"tsc --noEmit && tsc -p seed/tsconfig.json\";\n  const env = {\n    ...(process.stdout.isTTY && { FORCE_COLOR: \"1\" }),\n    ...process.env,\n  };\n  const { stdout, stderr } = await execPromise(CMD, { env });\n  if (stderr) throw new Error(stderr);\n  return stdout;\n}\n\nasync function runPrettier(): Promise<string> {\n  const CMD = \"prettier --prose-wrap always --write .\";\n  const env = {\n    ...(process.stdout.isTTY && { FORCE_COLOR: \"1\" }),\n    ...process.env,\n  };\n  const { stdout, stderr } = await execPromise(CMD, { env });\n  if (stderr) throw new Error(stderr);\n  return stdout;\n}\n\nasync function runAll(): Promise<void> {\n  const prom1 = runPrettier();\n  const prom2 = runEslint();\n  const prom3 = runTsc();\n\n  console.log(await prom1);\n  console.log(await prom2);\n  console.log(await prom3);\n}\n\nrunAll().catch(console.error);\n"
  },
  {
    "path": "build/spellcheck-dict.pws",
    "content": "personal_ws-1.1 en 0\ngenieacs\nGenieACS\njavascript\nsudo\nconfig\ncwmp\nCPE\nsystemctl\nnbi\nCWMP\nNBI\nACS\nSSL\nxsd\nargs\nDeviceID\nGENIEACS\nenv\nparam\narg\nAUTH\nigd\nTCP\ntoctree\nauth\nEnvironmentFile\nJSON\njson\nparameterValues\nsetParameterValues\nstderr\nstdout\nattr\ncfg\nCPEs\nExecStart\nfaq\nFileType\nGenieACS's\nJWT\nmaxdepth\nnpm\nobjectName\nrefreshObject\nSerialNumber\nsystemd\nusr\nWantedBy\nyaml\nchown\nConfig\ngetParameterValues\nhttps\nInternetGatewayDevice\nmkdir\nOUI\nsql\nTODO\nUDP\naddObject\nAGPLv\nconst\ncpe\ncron\ndeleteObject\nFactoryReset\nfactoryReset\nFileName\nfileType\nGithub\nhostname\nHTTPS\njwt\nlatlong\nNPM\noui\nparameterNames\nProductClass\nproductClass\nrepo\nSSID\nsublicense\nTLS\nWANIPConnection\nWPA\nYourProvisionName\nabc\nAbdulla\naccessors\nAddObject\nAddressingType\napi\nAPIs\nchmod\ncwmp's\ndateext\nDATETIME\ndateTime\ndatetime\ndeclaratively\ndelaycompress\nDeleteObject\ndeviceId\nDHCP\ndir\ndistro\nequisatisfiable\nExternalIPAddress\nfailover\nfavor\nfunc\nGetParameterAttributes\ngetPassword\ngte\ngui\nhexBinary\nHils\nHOSTNAME\ninit\njournalctl\njournald\nLastFileName\nLastFileType\nlastInform\nLIBXMLJS\nlibxmljs\nliteralinclude\nlockfile\nlogrotate\nlte\nmipsbe\nnormalizers\npre\nquickstart\nREADME\nredis\nreimplemented\nRequestDownload\nRESTful\nrollout\nsandboxed\nscalable\nSetParameterAttributes\nSetParamteerValues\nSIGINT\nSIGTERM\nskipWritableCheck\nstringify\nsubdirectory\nTargetFileName\nuseradd\nVirtualParameters\nvparam\nxml\nYAML\nZaid\ncacheExpire\niss\nhttp\nstatusCode\nrawData\npos\nBrotli\nCSV\nhostInfo\nIPv\ndownloadSuccessOnTimeout\nTransferComplete\nnginx\nENCODEURICOMPONENT\npageSize\nskipRootGpn\nGPN\nNaN\nCIDR\nXMPP\nPEM\nJID\nunindexed\n"
  },
  {
    "path": "build/spellcheck.sh",
    "content": "#! /bin/sh\n\ncd \"$(dirname \"$0\")\"\n\nFILES=`ls ../docs/*.rst ../docs/*.js ../*.md`\n\nfor FILE in $FILES\ndo\n    echo $FILE\n    cat $FILE | aspell list --lang=en --add-extra-dicts=./spellcheck-dict.pws --ignore 2\n    echo\ndone\n"
  },
  {
    "path": "build/test.ts",
    "content": "import path from \"node:path\";\nimport { readdir, readFile } from \"node:fs/promises\";\n\nimport * as esbuild from \"esbuild\";\n\nconst INPUT_DIR = process.cwd();\n\n// Redirect ui/store.ts imports to test/mocks/store.ts\nconst mockStorePlugin: esbuild.Plugin = {\n  name: \"mock-store\",\n  setup(build) {\n    const storePath = path.join(INPUT_DIR, \"ui/store.ts\");\n    const mockStorePath = path.join(INPUT_DIR, \"test/mocks/store.ts\");\n\n    build.onResolve({ filter: /\\.\\/store\\.ts$/ }, (args) => {\n      const resolved = path.join(args.resolveDir, args.path);\n      if (resolved === storePath) {\n        return { path: mockStorePath };\n      }\n      return undefined;\n    });\n  },\n};\n\n// Export private functions from reactive-store.ts for testing\nconst exportPrivateFunctionsPlugin: esbuild.Plugin = {\n  name: \"export-private-functions\",\n  setup(build) {\n    const reactiveStorePath = path.join(INPUT_DIR, \"ui/reactive-store.ts\");\n\n    build.onLoad({ filter: /reactive-store\\.ts$/ }, async (args) => {\n      if (args.path !== reactiveStorePath) return undefined;\n\n      let contents = await readFile(args.path, \"utf8\");\n\n      const exports = `\n// Test-only exports (added by build/test.ts)\nexport { compareFunction as _testCompareFunction };\nexport { getObjectId as _testGetObjectId };\nexport { applyDefaultSort as _testApplyDefaultSort };\nexport { stores as _testStores };\nexport { getStore as _testGetStore };\nexport { ResourceStore as _testResourceStore };\n`;\n      contents += exports;\n\n      return { contents, loader: \"ts\" };\n    });\n  },\n};\n\nasync function buildTests(): Promise<void> {\n  // Find all test files\n  const testFiles = (await readdir(path.join(INPUT_DIR, \"test\")))\n    .filter((f) => f.endsWith(\".ts\"))\n    .map((f) => path.join(\"test\", f));\n\n  await esbuild.build({\n    entryPoints: testFiles,\n    bundle: true,\n    platform: \"node\",\n    target: \"node18\",\n    packages: \"external\",\n    sourcemap: \"inline\",\n    outdir: \"test\",\n    logLevel: \"warning\",\n    plugins: [mockStorePlugin, exportPrivateFunctionsPlugin],\n  });\n}\n\nbuildTests().catch((err) => {\n  process.stderr.write(err.stack + \"\\n\");\n  process.exit(1);\n});\n"
  },
  {
    "path": "docs/.readthedocs.yaml",
    "content": "# Read the Docs configuration file for Sphinx projects\n# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details\n\nversion: 2\n\nbuild:\n  os: ubuntu-22.04\n  tools:\n    python: \"3.12\"\n\nsphinx:\n  configuration: docs/conf.py\n\nformats: all\n\npython:\n  install:\n    - requirements: docs/requirements.txt\n"
  },
  {
    "path": "docs/administration-faq.rst",
    "content": ".. _administration-faq:\n\nAdministration FAQ\n==================\n\n.. _administration-faq-duplicate-log-entries:\n\nDuplicate log entries when using :func:`log()` function\n-------------------------------------------------------\n\nBecause GenieACS uses a full fledged scripting language for device\nconfiguration, the only way to guarantee that it has satisfied the 'desired\nstate' is by repeatedly executing the script until there's no more\ndiscrepancies with the current device state. Though it may seem like this will\ncause duplicate requests going to the device, this isn't actually the case\nbecause device configuration are stated declaratively and that the scripts\nthemselves are pure functions in the context of a session (e.g. Date.now()\nalways returns the same value within the session).\n\nTo illustrate with an example, consider the following script:\n\n.. code:: javascript\n\n  log(\"Executing script\");\n  declare(\"Device.param\", null, {value: 1});\n  commit();\n  declare(\"Device.param\", null, {value: 2});\n\nThis will set the value of the 'Device.param' to 1, then to 2. Then as the\nscript is run again the value is set back to 1 and so on. A stable state will\nnever be reached so GenieACS will execute the script a few times until it gives\nup and throws a fault. This is an edge case that should be avoided. A more\ntypical case is where the script is run once or twice. Essentially if an\nexecution doesn't result in any request to the CPE or a change in the data\nmodel then a stable state is deemed to have been reached.\n\nConfigurations not pushed to device after factory reset\n---------------------------------------------------------\n\nAfter a device is reset to its factory default state, the cached data model in\nGenieACS's database needs to be invalidated to force rediscovery. Ensure the\nfollowing lines are called on ``0 BOOTSTRAP`` event:\n\n.. code:: javascript\n\n  const now = Date.now();\n\n  // Clear cached data model to force a refresh\n  clear(\"Device\", now);\n  clear(\"InternetGatewayDevice\", now);\n\n\nMost device parameters are missing\n----------------------------------\n\nFor performance reasons (server, client, and network), GenieACS by default only\nfetches parts of the data model that are necessary to satisfy the declarations\nin your provision scripts. Create declarations for any parameters you need\nfetched by default.\n\nIf you're unsure and want to explore the available parameters exposed by the\ndevice, refresh the root parameter (e.g. ``InternetGatewayDevice``) from\nGenieACS's UI. You typically only need to do that one time for a given CPE\nmodel.\n"
  },
  {
    "path": "docs/api-reference.rst",
    "content": "API Reference\n=============\n\nGenieACS exposes a rich RESTful API through its NBI component. This document\nserves as a reference for the available APIs.\n\nThis API makes use of MongoDB's query language in some of its endpoints. Refer\nto MongoDB's documentation for details.\n\n.. note::\n\n  The examples below use ``curl`` command for simplicity and ease of testing.\n  Query parameters are URL-encoded, but the original pre-encoding values are\n  shown for reference. These examples assume genieacs-nbi is running locally\n  and listening on the default NBI port (7557).\n\n.. warning::\n\n  A common pitfall is not properly percent-encoding special characters in the\n  device ID or query in the URL.\n\nEndpoints\n---------\n\nGET /\\<collection\\>/?query=\\<query\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nSearch for records in the database (e.g. devices, tasks, presets, files).\nReturns a JSON representation of all items in the given collection that match\nthe search criteria.\n\n*collection*: The data collection to search. Could be one of: tasks, devices,\npresets, objects.\n\n*query*: Search query. Refer to MongoDB queries for reference.\n\nExamples\n^^^^^^^^\n\n- Find a device by its ID:\n\n.. code:: javascript\n\n  query = {\"_id\": \"202BC1-BM632w-000000\"}\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/?query=%7B%22_id%22%3A%22202BC1-BM632w-000000%22%7D'\n\n- Find a device by its MAC address:\n\n.. code:: javascript\n\n  query = {\n    \"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress\": \"20:2B:C1:E0:06:65\"\n  }\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/?query=%7B%22InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress%22%3A%2220:2B:C1:E0:06:65%22%7D'\n\n- Search for devices that have not initiated an inform in the last 7 days.\n\n.. code:: javascript\n\n  query = {\n    \"_lastInform\": {\n      \"$lt\" : \"2017-12-11 13:16:23 +0000\"\n    }\n  }\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/?query=%7B%22_lastInform%22%3A%7B%22%24lt%22%3A%222017-12-11%2013%3A16%3A23%20%2B0000%22%7D%7D'\n\n- Show pending tasks for a given device:\n\n.. code:: javascript\n\n  query = {\"device\": \"202BC1-BM632w-000000\"}\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/tasks/?query=%7B%22device%22%3A%22202BC1-BM632w-000000%22%7D'\n\n- Return specific parameters for a given device:\n\n.. code:: javascript\n\n  query = {\"_id\": \"202BC1-BM632w-000000\"}\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices?query=%7B%22_id%22%3A%22202BC1-BM632w-000000%22%7D&projection=InternetGatewayDevice.DeviceInfo.ModelName,InternetGatewayDevice.DeviceInfo.Manufacturer'\n\nThe ``projection`` URL param is a comma-separated list of the parameters to receive.\n\nPOST /devices/\\<device_id\\>/tasks?[connection_request]\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nEnqueue task(s) and optionally trigger a connection request to the device.\nRefer to :ref:`tasks` section for information about the task object format.\nReturns status code 200 if the tasks have been successfully executed, and 202\nif the tasks have been queued to be executed at the next inform.\n\n*device_id*: The ID of the device.\n\n*connection_request*: Indicates that a connection request will be triggered to\nexecute the tasks immediately. Otherwise, the tasks will be queued and be\nprocessed at the next inform.\n\nThe response body is the task object as it is inserted in the database. The\nobject will include ``_id`` property which you can use to look up the task\nlater.\n\nExamples\n^^^^^^^^\n\n- Refresh all device parameters now:\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tasks?connection_request' \\\n  -X POST \\\n  --data '{\"name\": \"refreshObject\", \"objectName\": \"\"}'\n\n- Change WiFi SSID and password:\n\n.. code:: javascript\n\n  {\n    \"name\": \"setParameterValues\",\n    \"parameterValues\": [\n      [\"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID\", \"GenieACS\", \"xsd:string\"],\n      [\"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey\", \"hello world\", \"xsd:string\"]\n    ]\n  }\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tasks?connection_request' \\\n  -X POST \\\n  --data '{\"name\":\"setParameterValues\", \"parameterValues\": [[\"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID\", \"GenieACS\", \"xsd:string\"],[\"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey\", \"hello world\", \"xsd:string\"]]}'\n\nPOST /tasks/\\<task_id\\>/retry\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nRetry a faulty task at the next inform.\n\n*task_id*: The ID of the task as returned by 'GET /tasks' request.\n\nExample\n^^^^^^^\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/tasks/5403908ef28ea3a25c138adc/retry' -X POST\n\nDELETE /tasks/\\<task_id\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDelete the given task.\n\n*task_id*: The ID of the task as returned by 'GET /tasks' request.\n\nExample\n^^^^^^^\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/tasks/5403908ef28ea3a25c138adc' -X DELETE\n\nDELETE /faults/\\<fault_id\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDelete the given fault.\n\n*fault_id*: The ID of the fault as returned by 'GET /faults' request. The ID\nformat is \"\\<device_id\\>:\\<channel\\>\".\n\nExample\n^^^^^^^\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/faults/202BC1-BM632w-000000:default' -X DELETE\n\nDELETE /devices/\\<device_id\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDelete the given device from the database.\n\nExample\n^^^^^^^\n\n.. code:: bash\n\n  curl -X DELETE -i 'http://localhost:7557/devices/202BC1-BM632w-000001'\n\n.. note::\n\n  Note that the device will be registered again when/if it contacts the ACS\n  again (e.g. on the next periodic inform).\n\nPOST /devices/\\<device_id\\>/tags/\\<tag\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nAssign a tag to a device. Has no effect if such tag already exists.\n\n*device_id*: The ID of the device.\n\n*tag*: The tag to be assigned.\n\nExample\n^^^^^^^\n\nAssign the tag \"testing\" to a device:\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tags/testing' -X POST\n\nDELETE /devices/\\<device_id\\>/tags/\\<tag\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nRemove a tag from a device.\n\n*device_id*: The ID of the device.\n\n*tag*: The tag to be removed.\n\nExample\n^^^^^^^\n\nRemove the tag \"testing\" from a device:\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/202BC1-BM632w-000000/tags/testing' -X DELETE\n\nPUT /presets/\\<preset_name\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nCreate or update a preset. Returns status code 200 if the preset has been\nadded/updated successfully. The body of the request is a JSON representation of\nthe preset. Refer to :ref:`presets` section below for details about its format.\n\n*preset_name*: The name of the preset.\n\nExample\n^^^^^^^\n\nCreate a preset to set 5 minutes inform interval for all devices tagged with\n\"test\":\n\n.. code:: javascript\n\n  query = {\n    \"weight\": 0,\n    \"precondition\": \"{\\\"_tags\\\": \\\"test\\\"}\"\n    \"configurations\": [\n      {\n        \"type\": \"value\",\n        \"name\": \"InternetGatewayDevice.ManagementServer.PeriodicInformEnable\",\n        \"value\": \"true\"\n      },\n      {\n        \"type\": \"value\",\n        \"name\": \"InternetGatewayDevice.ManagementServer.PeriodicInformInterval\",\n        \"value\": \"300\"\n      }\n    ]\n  }\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/presets/inform' \\\n  -X PUT \\\n  --data '{\"weight\": 0, \"precondition\": \"{\\\"_tags\\\": \\\"test\\\"}\", \"configurations\": [{\"type\": \"value\", \"name\": \"InternetGatewayDevice.ManagementServer.PeriodicInformEnable\", \"value\": \"true\"}, {\"type\": \"value\", \"name\": \"InternetGatewayDevice.ManagementServer.PeriodicInformInterval\", \"value\": \"300\"}]}'\n\nDELETE /presets/\\<preset_name\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: bash\n\n\tcurl -i 'http://localhost:7557/presets/inform' -X DELETE\n\nPUT /files/\\<file_name\\>\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nUpload a new file or overwrite an existing one. Returns status code 200 if the\nfile has been added/updated successfully. The file content should be sent as\nthe request body.\n\n*file_name*: The name of the uploaded file.\n\nThe following file metadata may be sent as request headers:\n\n- ``fileType``: For firmware images it should be \"1 Firmware Upgrade Image\".\n  Other common types are \"2 Web Content\" and \"3 Vendor Configuration File\".\n\n- ``oui``: The OUI of the device model that this file belongs to.\n\n- ``productClass``: The product class of the device.\n\n- ``version``: In case of firmware images, this refer to the firmware version.\n\nExample\n^^^^^^^\n\nUpload a firmware image file:\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/files/new_firmware_v1.0.bin' \\\n  -X PUT \\\n  --data-binary @\"./new_firmware_v1.0.bin\" \\\n  --header \"fileType: 1 Firmware Upgrade Image\" \\\n  --header \"oui: 123456\" \\\n  --header \"productClass: ABC\" \\\n  --header \"version: 1.0\"\n\nDELETE /files/\\<file_name\\>\n~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nDelete a previously uploaded file:\n\n.. code:: bash\n\n\tcurl -i 'http://localhost:7557/files/new_firmware_v1.0.bin' -X DELETE\n\nGET /files/\n~~~~~~~~~~~\n\nGets all previously uploaded files.\n\nGET /files/?query={\"filename\":\"\\<filename\\>\"}\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nFind files using a query.\n\n.. _tasks:\n\nTasks\n-----\n\nFind the different available tasks and their object structure.\n\n``getParameterValues``\n~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: javascript\n\n  query = {\n    \"name\": \"getParameterValues\",\n    \"parameterNames\": [\n      \"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnectionNumberOfEntries\",\n      \"InternetGatewayDevice.Time.NTPServer1\", \"InternetGatewayDevice.Time.Status\"\n    ]\n  }\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-96318REF-SR360NA0A4%252D0003196/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\": \"getParameterValues\", \"parameterNames\": [\"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnectionNumberOfEntries\", \"InternetGatewayDevice.Time.NTPServer1\", \"InternetGatewayDevice.Time.Status\"] }'\n\nYou may request a single or multiple parameters at once.\n\nAfter the task has been executed successfully you can then fetch the CPE object\nand read the parameters from the JSON object.\n\n.. code:: javascript\n\n  query = {\"_id\": \"00236a-96318REF-SR360NA0A4%2D0003196\"}\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/?query=%7B%22_id%22%3A%2200236a-96318REF-SR360NA0A4%252D0003196%22%7D'\n\n``refreshObject``\n~~~~~~~~~~~~~~~~~\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-SR552n-SR552NA084%252D0003269/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\": \"refreshObject\", \"objectName\": \"InternetGatewayDevice.WANDevice.1.WANConnectionDevice\"}'\n\n``setParameterValues``\n~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-SR552n-SR552NA084%252D0003269/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\": \"setParameterValues\", \"parameterValues\": [[\"InternetGatewayDevice.ManagementServer.UpgradesManaged\",false]]}'\n\nMultiple values can be set at once by adding multiple arrays to the\nparameterValues key. For example:\n\n.. code:: javascript\n\n  {\n    name: \"setParameterValues\",\n    parameterValues: [[\"InternetGatewayDevice.ManagementServer.UpgradesManaged\", false], [\"InternetGatewayDevice.Time.Enable\", true], [\"InternetGatewayDevice.Time.NTPServer1\", \"pool.ntp.org\"]]\n  }\n\n``addObject``\n~~~~~~~~~~~~~\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-SR552n-SR552NA084%252D0003269/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\":\"addObject\",\"objectName\":\"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection\"}'\n\n``deleteObject``\n~~~~~~~~~~~~~~~~\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-SR552n-SR552NA084%252D0003269/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\":\"deleteObject\",\"objectName\":\"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1\"}'\n\n``reboot``\n~~~~~~~~~~\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-SR552n-SR552NA084%252D0003269/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\": \"reboot\"}'\n\n``factoryReset``\n~~~~~~~~~~~~~~~~\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-SR552n-SR552NA084%252D0003269/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\": \"factoryReset\"}'\n\n``download``\n~~~~~~~~~~~~\n\n.. code:: bash\n\n  curl -i 'http://localhost:7557/devices/00236a-SR552n-SR552NA084%252D0003269/tasks?timeout=3000&connection_request' \\\n  -X POST \\\n  --data '{\"name\": \"download\", \"file\": \"mipsbe-6-42-lite.xml\"}'\n\n.. _presets:\n\nPresets\n-------\n\nPresets assign a set of configuration or a Provision script to devices based on\na precondition (search filter), schedule (cron expression), and events.\n\nPrecondition\n~~~~~~~~~~~~\n\nThe ``precondition`` property is a JSON string representation of the search\nfilter to test if the preset applies to a given device. Examples preconditions\nare:\n\n- ``{\"param\": \"value\"}``\n- ``{\"param\": value\", \"param2\": {\"$ne\": \"value2\"}}``\n\nOther operators that can be used are ``$gt``, ``$lt``, ``$gte`` and ``$lte``.\n\nConfiguration\n~~~~~~~~~~~~~\n\nThe configuration property is an array containing the different configurations\nto be applied to a device, as shown below:\n\n.. code:: javascript\n\n  [\n    {\n      \"type\": \"value\",\n      \"name\": \"InternetGatewayDevice.ManagementServer.PeriodicInformEnable\",\n      \"value\": \"true\"\n    },\n    {\n      \"type\": \"value\",\n      \"name\": \"InternetGatewayDevice.ManagementServer.PeriodicInformInterval\",\n      \"value\": \"300\"\n    },\n    {\n      \"type\": \"delete_object\",\n      \"name\": \"object_parent\",\n      \"object\": \"object_name\"\n    },\n    {\n      \"type\": \"add_object\",\n      \"name\": \"object_parent\",\n      \"object\": \"object_name\"\n    },\n    {\n      \"type\": \"provision\",\n      \"name\": \"YourProvisionName\"\n    },\n  ] \n\nThe configuration type ``provision`` triggers a Provision script. In the\nexample above, the provision named \"YourProvisionName\" will be executed.\n\nProvisions\n----------\n\nCreate a provision\n~~~~~~~~~~~~~~~~~~\n\nThe Provision's JavaScript code is the body of the HTTP PUT request.\n\n.. code:: bash\n\n  curl -X PUT -i 'http://localhost:7557/provisions/mynewprovision' --data 'log(\"Provision started at \" + now);'\n\nDelete a provision\n~~~~~~~~~~~~~~~~~~\n\n.. code:: bash\n\n  curl -X DELETE -i 'http://localhost:7557/provisions/mynewprovision'\n\nGet provisions\n~~~~~~~~~~~~~~\n\nGet all provisions:\n\n.. code:: bash\n\n  curl -X GET -i 'http://localhost:7557/provisions/'\n"
  },
  {
    "path": "docs/conf.py",
    "content": "# Configuration file for the Sphinx documentation builder.\n#\n# This file only contains a selection of the most common options. For a full\n# list see the documentation:\n# http://www.sphinx-doc.org/en/master/config\n\n# -- Path setup --------------------------------------------------------------\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\n#\n# import os\n# import sys\n# sys.path.insert(0, os.path.abspath('.'))\n\nimport json\n\n# -- Project information -----------------------------------------------------\n\nproject = 'GenieACS Documentation'\ncopyright = '2024, GenieACS Inc.'\nauthor = 'GenieACS Inc.'\n\n# The full version, including alpha/beta/rc tags\nrelease = json.load(open(\"../package.json\"))[\"version\"]\n\n\n# -- General configuration ---------------------------------------------------\n\n# Add any Sphinx extension module names here, as strings. They can be\n# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom\n# ones.\nextensions = [\n]\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = ['_templates']\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\n# This pattern also affects html_static_path and html_extra_path.\nexclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']\n\n\n# -- Options for HTML output -------------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\n#\n# html_theme = 'alabaster'\nhtml_theme = 'sphinx_rtd_theme'\nhtml_logo = \"logo.svg\"\nhtml_theme_options = {\n  \"logo_only\": True\n}\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\n# html_static_path = ['_static']\n\nmaster_doc = 'index'\n\nhighlight_language = 'javascript'"
  },
  {
    "path": "docs/cpe-authentication.rst",
    "content": ".. _cpe-authentication:\n\nCPE Authentication\n==================\n\nCPE to ACS\n----------\n\n.. note::\n\n  By default GenieACS will accept any incoming connection via HTTP/HTTPS and\n  respond to it.\n\nThe following parameters are used to set and get (password is redacted but\ncan be set) the username/password used to authenticate against the ACS:\n\nUsername: ``Device.ManagementServer.Username`` or ``InternetGatewayDevice.ManagementServer.Username``\n\nPassword: ``Device.ManagementServer.Password`` or ``InternetGatewayDevice.ManagementServer.Password``\n\nEnable CPE to ACS Authentication\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nCPE to ACS authentication can be configured in the web interface by using the\n`Config` option in the `Admin` tab.\n\nGo to the `Admin` -> `Config` page and click on `New config` button at the\nbottom of the page. This will open pop-up which requires you to fill in a key\nand value. The key should be ``cwmp.auth``. The value accepts a boolean.\nSetting the value to ``true`` makes it so that GenieACS accepts any incoming\nconnection, setting it to ``false`` makes GenieACS deny all incoming\nconnections. This can be further configured using the ``AUTH()`` and ``EXT()``\nfunctions.\n\nThe ``AUTH()`` function\n~~~~~~~~~~~~~~~~~~~~~~~\n\nThe ``AUTH()`` function accepts two parameters, username and password. It\nchecks the given username and password with the incoming request to determine\nwhether to return true or false.\n\nBasic usage of the ``AUTH()`` function could be as follows:\n\n.. code:: sql\n\n   AUTH(\"fixed-username\", \"fixed-password\")\n\nThis will only accept incoming request who authenticate with\n\"fixed-username\" and \"fixed-password\".\n\nThe various device parameters can be referenced from within the ``cwmp.auth``\nexpression. For example:\n\n.. code:: sql\n\n   AUTH(Device.ManagementServer.Username, Device.ManagementServer.Password)\n\nThe ``EXT()`` function\n~~~~~~~~~~~~~~~~~~~~~~\n\nThe ``EXT()`` function makes it possible to call an :ref:`extension\n<extensions>` script from the auth expression. This can be used to fetch\nthe credentials from an external source:\n\n.. code:: sql\n\n   AUTH(DeviceID.SerialNumber, EXT(\"authenticate\", \"getPassword\", DeviceID.SerialNumber))\n\nACS to CPE\n----------\n\nTODO\n"
  },
  {
    "path": "docs/environment-variables.rst",
    "content": ".. _environment-variables:\n\nEnvironment Variables\n=====================\n\nConfiguring GenieACS services can be done through the following environment\nvariables:\n\n.. attention::\n\n  All GenieACS environment variables must be prefixed with ``GENIEACS_``.\n\nMONGODB_CONNECTION_URL\n  MongoDB connection string.\n\n  Default: ``mongodb://127.0.0.1/genieacs``\n\nEXT_DIR\n  The directory from which to look up extension scripts.\n\n  Default: ``<installation dir>/config/ext``\n\nEXT_TIMEOUT\n  Timeout (in milliseconds) to allow for calls to extensions to return a\n  response.\n\n  Default: ``3000``\n\nDEBUG_FILE\n  File to dump CPE debug log.\n\n  Default: unset\n\nDEBUG_FORMAT\n  Debug log format. Valid values are 'yaml' and 'json'.\n\n  Default: ``yaml``\n\nLOG_FORMAT\n  The format used for the log entries in ``CWMP_LOG_FILE``, ``NBI_LOG_FILE``,\n  ``FS_LOG_FILE``, and ``UI_LOG_FILE``. Possible values are ``simple`` and\n  ``json``.\n\n  Default: ``simple``\n\nACCESS_LOG_FORMAT\n  The format used for the log entries in ``CWMP_ACCESS_LOG_FILE``,\n  ``NBI_ACCESS_LOG_FILE``, ``FS_ACCESS_LOG_FILE``, and ``UI_ACCESS_LOG_FILE``.\n  Possible values are ``simple`` and ``json``.\n\n  Default: ``simple``\n\nCWMP_WORKER_PROCESSES\n  The number of worker processes to spawn for genieacs-cwmp. A value of 0 means\n  as many as there are CPU cores available.\n\n  Default: ``0``\n\nCWMP_PORT\n  The TCP port that genieacs-cwmp listens on.\n\n  Default: ``7547``\n\nCWMP_INTERFACE\n  The network interface that genieacs-cwmp binds to.\n\n  Default: ``::``\n\nCWMP_SSL_CERT\n  Path to certificate file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nCWMP_SSL_KEY\n  Path to certificate key file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nCWMP_LOG_FILE\n  File to log process related events for genieacs-cwmp. If omitted, logs will\n  go to stderr.\n\n  Default: unset\n\nCWMP_ACCESS_LOG_FILE\n  File to log incoming requests for genieacs-cwmp. If omitted, logs will go to\n  stdout.\n\n  Default: unset\n\nNBI_WORKER_PROCESSES\n  The number of worker processes to spawn for genieacs-nbi. A value of 0 means\n  as many as there are CPU cores available.\n\n  Default: ``0``\n\nNBI_PORT\n  The TCP port that genieacs-nbi listens on.\n\n  Default: ``7557``\n\nNBI_INTERFACE\n  The network interface that genieacs-nbi binds to.\n\n  Default: ``::``\n\nNBI_SSL_CERT\n  Path to certificate file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nNBI_SSL_KEY\n  Path to certificate key file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nNBI_LOG_FILE\n  File to log process related events for genieacs-nbi. If omitted, logs will go\n  to stderr.\n\n  Default: unset\n\nNBI_ACCESS_LOG_FILE\n  File to log incoming requests for genieacs-nbi. If omitted, logs will go to\n  stdout.\n\n  Default: unset\n\nFS_WORKER_PROCESSES\n  The number of worker processes to spawn for genieacs-fs. A value of 0 means\n  as many as there are CPU cores available.\n\n  Default: ``0``\n\nFS_PORT\n  The TCP port that genieacs-fs listens on.\n\n  Default: ``7567``\n\nFS_INTERFACE\n  The network interface that genieacs-fs binds to.\n\n  Default: ``::``\n\nFS_SSL_CERT\n  Path to certificate file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nFS_SSL_KEY\n  Path to certificate key file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nFS_LOG_FILE\n  File to log process related events for genieacs-fs. If omitted, logs will go\n  to stderr.\n\n  Default: unset\n\nFS_ACCESS_LOG_FILE\n  File to log incoming requests for genieacs-fs. If omitted, logs will go to\n  stdout.\n\n  Default: unset\n\nFS_URL_PREFIX\n  The URL prefix (e.g. 'https://example.com:7567/') to use when generating the\n  file URL for TR-069 Download requests. Set this if genieacs-fs and\n  genieacs-cwmp are behind a proxy or running on different servers.\n\n  Default: auto generated based on the hostname from the ACS URL, FS_PORT\n  config, and whether or not SSL is enabled for genieacs-fs.\n\nUI_WORKER_PROCESSES\n  The number of worker processes to spawn for genieacs-ui. A value of 0 means\n  as many as there are CPU cores available.\n\n  Default: ``0``\n\nUI_PORT\n  The TCP port that genieacs-ui listens on.\n\n  Default: ``3000``\n\nUI_INTERFACE\n  The network interface that genieacs-ui binds to.\n\n  Default: ``::``\n\nUI_SSL_CERT\n  Path to certificate file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nUI_SSL_KEY\n  Path to certificate key file. If omitted, non-secure HTTP will be used.\n\n  Default: unset\n\nUI_LOG_FILE\n  File to log process related events for genieacs-ui. If omitted, logs will go\n  to stderr.\n\n  Default: unset\n\nUI_ACCESS_LOG_FILE\n  File to log incoming requests for genieacs-ui. If omitted, logs will go to\n  stdout.\n\n  Default: unset\n\nUI_JWT_SECRET\n  The key used for signing JWT tokens that are stored in browser cookies. The\n  string can be up to 64 characters in length.\n\n  Default: unset\n"
  },
  {
    "path": "docs/ext-sample.js",
    "content": "// This is an example GenieACS extension to get the current latitude/longitude\n// of the International Space Station. Why, you ask? Because why not.\n// To install, copy this file to config/ext/iss.js.\n\n\"use strict\";\n\nconst http = require(\"http\");\n\nlet cache = null;\nlet cacheExpire = 0;\n\nfunction latlong(args, callback) {\n  if (Date.now() < cacheExpire) return callback(null, cache);\n\n  http\n    .get(\"http://api.open-notify.org/iss-now.json\", (res) => {\n      if (res.statusCode !== 200)\n        return callback(\n          new Error(`Request failed (status code: ${res.statusCode})`),\n        );\n\n      let rawData = \"\";\n      res.on(\"data\", (chunk) => (rawData += chunk));\n\n      res.on(\"end\", () => {\n        let pos = JSON.parse(rawData)[\"iss_position\"];\n        cache = [+pos[\"latitude\"], +pos[\"longitude\"]];\n        cacheExpire = Date.now() + 10000;\n        callback(null, cache);\n      });\n    })\n    .on(\"error\", (err) => {\n      callback(err);\n    });\n}\n\nexports.latlong = latlong;\n"
  },
  {
    "path": "docs/extensions.rst",
    "content": ".. _extensions:\n\nExtensions\n==========\n\nGiven that :ref:`provisions` and :ref:`virtual-parameters` are executed in a\nsandbox environment, it is not possible to interact with external sources or\nexecute any action that requires OS, file system, or network access. Extensions\nexist to bridge that gap.\n\nExtensions are fully-privileged Node.js modules and as such have access to\nstandard Node libraries and 3rd party packages. Functions exposed by the\nextension can be called from Provision scripts using the ``ext()`` function. A\ntypical use case for extensions is fetching credentials from a database to have\nthat pushed to the device during provisioning.\n\nBy default, the extension JS code must be placed under ``config/ext``\ndirectory. You may need to create that directory if it doesn't already exist.\n\nThe example extension below fetches data from an external REST API and returns\nthat to the caller:\n\n.. literalinclude:: ext-sample.js\n  :language: javascript\n\nTo call this extension from a Provision or a Virtual Parameter script:\n\n.. code:: javascript\n\n  // The arguments \"arg1\" and \"arg2\" are passed to the latlong. Though they are\n  // unused in this particular example.\n  const res = ext(\"ext-sample\", \"latlong\", \"arg1\", \"arg2\");\n  log(JSON.stringify(res));\n"
  },
  {
    "path": "docs/https.rst",
    "content": ".. _https:\n\nHTTPS\n=====\n\nTODO\n"
  },
  {
    "path": "docs/index.rst",
    "content": ".. GenieACS documentation master file, created by\n   sphinx-quickstart on Wed Jun  5 13:47:06 2019.\n   You can adapt this file completely to your liking, but it should at least\n   contain the root `toctree` directive.\n\nWelcome to GenieACS's documentation!\n====================================\n\n.. toctree::\n  :caption: Table of Contents\n\n.. raw:: latex\n\n   \\part{Installation}\n\n.. toctree::\n  :maxdepth: 1\n  :caption: Installation\n\n  installation-guide\n  environment-variables\n\n.. raw:: latex\n\n   \\part{Administration}\n\n.. toctree::\n  :maxdepth: 1\n  :caption: Administration\n\n  provisions\n  virtual-parameters\n  administration-faq\n\n.. raw:: latex\n\n   \\part{Integration}\n\n.. toctree::\n  :maxdepth: 1\n  :caption: Integration\n\n  extensions\n  api-reference\n\n.. raw:: latex\n\n   \\part{Security}\n\n.. toctree::\n  :maxdepth: 1\n  :caption: Security\n\n  https\n  cpe-authentication\n  roles-and-permissions\n"
  },
  {
    "path": "docs/installation-guide.rst",
    "content": "Installation Guide\n==================\n\nThis guide is for installing GenieACS on a single server on any Linux distro\nthat uses *systemd* as its init system.\n\nThe various GenieACS services are independent of each other and may be\ninstalled on different servers. You may also run multiple instances of each in\na load-balancing/failover setup.\n\n.. attention::\n\n  For production deployments make sure to configure TLS and change\n  ``UI_JWT_SECRET`` to a unique and secure string. Refer to :ref:`https`\n  section for how to enable TLS to encrypt traffic.\n\nPrerequisites\n-------------\n\n.. topic:: Node.js\n\n  GenieACS requires Node.js 12.13 and up. Refer to https://nodejs.org/ for\n  instructions.\n\n.. topic:: MongoDB\n\n  GenieACS requires MongoDB 3.6 and up. Refer to https://www.mongodb.com/ for\n  instructions.\n\nInstall GenieACS\n-------------------\n\n.. topic:: Installing from NPM:\n\n  .. parsed-literal::\n\n    sudo npm install -g genieacs@\\ |release|\n\n.. topic:: Installing from source\n\n  If you prefer installing from source, such as when running a GenieACS copy\n  with custom patches, refer to README.md file in the source package. Adjust\n  the next steps below accordingly.\n\nConfigure systemd\n-----------------\n\n.. topic:: Create a system user to run GenieACS daemons\n\n  .. code:: bash\n\n    sudo useradd --system --no-create-home --user-group genieacs\n\n.. topic:: Create directory to save extensions and environment file\n\n  We'll use :file:`/opt/genieacs/ext/` directory to store extension scripts (if any).\n\n  .. code:: bash\n    \n    mkdir /opt/genieacs\n    mkdir /opt/genieacs/ext\n    chown genieacs:genieacs /opt/genieacs/ext\n\n  Create the file :file:`/opt/genieacs/genieacs.env` to hold our configuration\n  options which we pass to GenieACS as environment variables. See\n  :ref:`environment-variables` section for a list of all available\n  configuration options.\n\n  .. code:: bash\n\n    GENIEACS_CWMP_ACCESS_LOG_FILE=/var/log/genieacs/genieacs-cwmp-access.log\n    GENIEACS_NBI_ACCESS_LOG_FILE=/var/log/genieacs/genieacs-nbi-access.log\n    GENIEACS_FS_ACCESS_LOG_FILE=/var/log/genieacs/genieacs-fs-access.log\n    GENIEACS_UI_ACCESS_LOG_FILE=/var/log/genieacs/genieacs-ui-access.log\n    GENIEACS_DEBUG_FILE=/var/log/genieacs/genieacs-debug.yaml\n    NODE_OPTIONS=--enable-source-maps\n    GENIEACS_EXT_DIR=/opt/genieacs/ext\n\n  Generate a secure JWT secret and append to :file:`/opt/genieacs/genieacs.env`:\n\n  .. code:: bash\n\n    node -e \"console.log(\\\"GENIEACS_UI_JWT_SECRET=\\\" + require('crypto').randomBytes(128).toString('hex'))\" >> /opt/genieacs/genieacs.env\n  \n  Set file ownership and permissions:\n\n  .. code:: bash\n\n    sudo chown genieacs:genieacs /opt/genieacs/genieacs.env\n    sudo chmod 600 /opt/genieacs/genieacs.env\n\n.. topic:: Create logs directory\n\n  .. code:: bash\n    \n    mkdir /var/log/genieacs\n    chown genieacs:genieacs /var/log/genieacs\n\n.. topic:: Create systemd unit files\n\n  Create a systemd unit file for each of the four GenieACS services. Note that\n  we're using EnvironmentFile directive to read the environment variables from\n  the file we created earlier.\n\n  Each service has two streams of logs: access log and process log. Access logs\n  are configured here to be dumped in a log file under\n  :file:`/var/log/genieacs/` while process logs go to *journald*. Use\n  ``journalctl`` command to view process logs.\n\n  .. attention::\n\n    If the command :command:`systemctl edit --force --full` fails, you can\n    create the unit file manually.\n\n  1. Run the following command to create ``genieacs-cwmp`` service:\n  \n    .. code:: bash\n\n      sudo systemctl edit --force --full genieacs-cwmp\n    \n    Then paste the following in the editor and save:\n\n    .. code:: cfg\n\n      [Unit]\n      Description=GenieACS CWMP\n      After=network.target\n\n      [Service]\n      User=genieacs\n      EnvironmentFile=/opt/genieacs/genieacs.env\n      ExecStart=/usr/bin/genieacs-cwmp\n\n      [Install]\n      WantedBy=default.target\n\n  2. Run the following command to create ``genieacs-nbi`` service:\n  \n    .. code:: bash\n\n      sudo systemctl edit --force --full genieacs-nbi\n    \n    Then paste the following in the editor and save:\n\n    .. code:: cfg\n\n      [Unit]\n      Description=GenieACS NBI\n      After=network.target\n\n      [Service]\n      User=genieacs\n      EnvironmentFile=/opt/genieacs/genieacs.env\n      ExecStart=/usr/bin/genieacs-nbi\n\n      [Install]\n      WantedBy=default.target\n\n  3. Run the following command to create ``genieacs-fs`` service:\n  \n    .. code:: bash\n\n      sudo systemctl edit --force --full genieacs-fs\n    \n    Then paste the following in the editor and save:\n\n    .. code:: cfg\n\n      [Unit]\n      Description=GenieACS FS\n      After=network.target\n\n      [Service]\n      User=genieacs\n      EnvironmentFile=/opt/genieacs/genieacs.env\n      ExecStart=/usr/bin/genieacs-fs\n\n      [Install]\n      WantedBy=default.target\n\n  4. Run the following command to create ``genieacs-ui`` service:\n  \n    .. code:: bash\n\n      sudo systemctl edit --force --full genieacs-ui\n    \n    Then paste the following in the editor and save:\n\n    .. code:: cfg\n\n      [Unit]\n      Description=GenieACS UI\n      After=network.target\n\n      [Service]\n      User=genieacs\n      EnvironmentFile=/opt/genieacs/genieacs.env\n      ExecStart=/usr/bin/genieacs-ui\n\n      [Install]\n      WantedBy=default.target\n\n.. topic:: Configure log file rotation using logrotate\n\n  Save the following as :file:`/etc/logrotate.d/genieacs`\n\n  .. code::\n  \n    /var/log/genieacs/*.log /var/log/genieacs/*.yaml {\n        daily\n        rotate 30\n        compress\n        delaycompress\n        dateext\n    }\n\n.. topic:: Enable and start services\n\n  .. code:: bash\n\n    sudo systemctl enable genieacs-cwmp\n    sudo systemctl start genieacs-cwmp\n    sudo systemctl status genieacs-cwmp\n\n    sudo systemctl enable genieacs-nbi\n    sudo systemctl start genieacs-nbi\n    sudo systemctl status genieacs-nbi\n\n    sudo systemctl enable genieacs-fs\n    sudo systemctl start genieacs-fs\n    sudo systemctl status genieacs-fs\n\n    sudo systemctl enable genieacs-ui\n    sudo systemctl start genieacs-ui\n    sudo systemctl status genieacs-ui\n\n  Review the status message for each to verify that the services are running\n  successfully.\n"
  },
  {
    "path": "docs/provisions.rst",
    "content": ".. _provisions:\n\nProvisions\n==========\n\nA Provision is a piece of JavaScript code that is executed on the server on a\nper-device basis. It enables implementing complex provisioning scenarios and\nother operations such as automated firmware upgrade rollout. Apart from a few\nspecial functions, the script is essentially a standard ES6 code executed in\nstrict mode.\n\nProvisions are mapped to devices using presets. Note that the added performance\noverhead when using Provisions as opposed to simple preset configuration\nentries is relatively small. Anything that can be done via preset\nconfigurations can be done using a Provision script. In fact, the now\ndeprecated configuration format is still supported primarily for backward\ncompatibility and it is recommended to use Provision scripts for all\nconfiguration.\n\nWhen assigning a Provision script to a preset, you may pass arguments to the\nscript. The arguments can be accessed from the script through the global\n``args`` variable.\n\n.. note::\n\n  Provision scripts may get executed multiple times in a given session.\n  Although all data model-mutating operations are idempotent, a script as a\n  whole may not be. It is, therefore, necessary to repeatedly run the script\n  until there are no more side effects and a stable state is reached.\n\nBuilt-in functions\n------------------\n\n``declare(path, timestamps, values)``\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThis function is for declaring parameter values to be set, as well as specify\nconstraints on how recent you'd like the parameter value (or other attributes)\nto have been refreshed from the device. If the given timestamp is lower than\nthe timestamp of the last refresh from the device, then this function will\nreturn the last known value. Otherwise, the value will be fetched from the\ndevice before being returned to the caller.\n\nThe timestamp argument is an object where the key is the attribute name (e.g.\n``value``, ``object``, ``writable``, ``path``) and the value is an integer\nrepresenting a Unix timestamp.\n\nThe values argument is an object similar to the timestamp argument but its\nproperty values being the parameter values to be set.\n\nThe possible attributes in 'timestamps' and 'values' arguments are:\n\n- ``value``: a [<value>, <type>] pair\n\nThis attribute is not available for objects or object instances. If the value\nis not a [<value>, <type>] array then it'll assumed to be a value without a\ntype and therefore the type will be inferred from the parameter's type.\n\n- ``writable``: boolean\n\nThe meaning of this attribute can vary depending on the type of the parameter.\nIn the case of regular parameters, it indicates if its value is writable. In\nthe case of objects, it's whether or not it's possible to add new object\ninstances. In the case of object instances, it indicates whether or not this\ninstance can be deleted.\n\n- ``object``: boolean\n\nTrue if this is an object or object instance, false otherwise.\n\n- ``path``: string\n\nThis attribute is special in that it's not a parameter attribute per se, but it\nrefers to the presence of parameters matching the given path. For example,\ngiven the following wildcard path:\n\n``InternetGatewayDevice.LANDevice.1.Hosts.Host.*.MACAddress``\n\nUsing a recent timestamp for path in ``declare()`` will result in a sync with\nthe device to rediscover all Host instances (``Host.*``). The path attribute\ncan also be used to create or delete object instances as described in\n:ref:`path-format` section.\n\nThe return value of ``declare()`` is an iterator to access parameters that\nmatch the given path. Each item in the iterator has the attribute 'path' in\naddition to any other attribute given in the ``declare()`` call. The iterator\nobject itself has convenience attribute accessors which come in handy when\nyou're expecting a single parameter (e.g. when path does not contain wildcards\nor aliases).\n\n.. code:: javascript\n\n  // Example: Setting the SSID as the last 6 characters of the serial number\n  let serial = declare(\"Device.DeviceInfo.SerialNumber\", {value: 1});\n  declare(\"Device.LANDevice.1.WLANConfiguration.1.SSID\", null, {value: serial.value[0]});\n\n``clear(path, timestamp)``\n~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nThis function invalidates the database copy of parameters (and their child\nparameters) that match the given path and have a last refresh timestamp that is\nless than the given timestamp. The most obvious use for this function is to\ninvalidate the database copy of the entire data model after the device has been\nfactory reset:\n\n.. code:: javascript\n\n  // Example: Clear cached device data model Note\n  // Make sure to apply only on \"0 BOOTSTRAP\" event\n  clear(\"Device\", Date.now());\n  clear(\"InternetGatewayDevice\", Date.now());\n\n``commit()``\n~~~~~~~~~~~~\n\nThis function commits the pending declarations and performs any necessary sync\nwith the device. It's usually not required to call this function as it called\nimplicitly at the end of the script and when accessing any property of the\npromise-like object returned by the ``declare()`` function. Calling this\nexplicitly is only necessary if you want to control the order in which\nparameters are configured.\n\n``ext(file, function, arg1, arg2, ...)``\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nExecute an extension script and return the result. The first argument is the\nscript filename while second argument is the function name within that script.\nAny remaining arguments will be passed to that function. See :ref:`extensions`\nfor more details.\n\n``log(message)``\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\nPrints out a string in genieacs-cwmp's access log. It's meant to be used for\ndebugging. Note that you may see multiple log entries as the script can be\nexecuted multiple times in a session. See :ref:`this FAQ\n<administration-faq-duplicate-log-entries>`.\n\n.. _path-format:\n\nPath format\n-----------\n\nA parameter path may contain a wildcard (``*``) or an alias filter\n(``[name:value]``). A wildcard segment in a parameter path will apply the\ndeclared configuration to zero or more parameters that match the given path\nwhere the wildcard segment can be anything.\n\nAn alias filter is like a wildcard, but additionally performs filtering on the\nchild parameters based on the key-value pairs provided. For example, the\nfollowing path:\n\n``Device.WANDevice.1.WANConnectionDevice.1.WANIPConnection.[AddressingType:DHCP].ExternalIPAddress``\n\nwill return a list of ExternalIPAddress parameters (0 or more) where the\nsibling parameter AddressingType is assigned the value \"DHCP\".\n\nThis can be useful when the exact instance numbers may be different from one\ndevice to another. It is possible to use more than one key-value pair in the\nalias filter. It's also possible to use multiple filters or use a combination\nof filters and wildcards.\n\nCreating/deleting object instances\n----------------------------------\n\nGiven the declarative nature of provisions, we cannot explicitly tell the\ndevice to create or delete an instance under a given object. Instead, we\nspecify the number of instances we want there to be, and based on that GenieACS\nwill determine whether or not it needs to create or delete instances. For\nexample, the following declaration will ensure we have one and only one\nWANIPConnection object:\n\n.. code:: javascript\n\n  // Example: Ensure we have one and only one WANIPConnection object\n  declare(\"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.*\", null, {path: 1});\n\nNote the wildcard at the end of the parameter path.\n\nIt is also possible to use alias filters as the last path segment which will\nensure that the declared number of instances is satisfied given the alias\nfilter:\n\n.. code:: javascript\n\n  // Ensure that *all* other instances are deleted\n  declare(\"InternetGatewayDevice.X_BROADCOM_COM_IPAddrAccCtrl.X_BROADCOM_COM_IPAddrAccCtrlListCfg.[]\", null, {path: 0});\n\n  // Add the two entries we care about\n  declare(\"InternetGatewayDevice.X_BROADCOM_COM_IPAddrAccCtrl.X_BROADCOM_COM_IPAddrAccCtrlListCfg.[SourceIPAddress:192.168.1.0,SourceNetMask:255.255.255.0]\",  {path: now}, {path: 1});\n  declare(\"InternetGatewayDevice.X_BROADCOM_COM_IPAddrAccCtrl.X_BROADCOM_COM_IPAddrAccCtrlListCfg.[SourceIPAddress:172.16.12.0,SourceNetMask:255.255.0.0]\", {path: now}, {path: 1});\n\nSpecial GenieACS parameters\n---------------------------\n\nIn addition to the parameters exposed in the device's data model through\nTR-069, GenieACS has its own set of special parameters:\n\n``DeviceID``\n~~~~~~~~~~~~\n\nThis parameter sub-tree includes the following read-only parameters:\n\n- ``DeviceID.ID``\n- ``DeviceID.SerialNumber``\n- ``DeviceID.ProductClass``\n- ``DeviceID.OUI``\n- ``DeviceID.Manufacturer``\n\n``Tags``\n~~~~~~~~\n\nThe ``Tags`` root parameter is used to expose device tags in the data model.\nTags appear as child parameters that are writable and have boolean value.\nSetting a tag to ``false`` will delete that tag, and setting the value of a\nnon-existing tag parameter to ``true`` will create it.\n\n.. code:: javascript\n\n  // Example: Remove \"tag1\", add \"tag2\", and read \"tag3\"\n  declare(\"Tags.tag1\", null, {value: false});\n  declare(\"Tags.tag2\", null, {value: true});\n  let tag3 = declare(\"Tags.tag3\", {value: 1});\n\n``Reboot``\n~~~~~~~~~~\n\nThe ``Reboot`` root parameter hold the timestamp of the last reboot command.\nThe parameter value is writable and declaring a timestamp value that is larger\nthan the current value will trigger a reboot.\n\n.. code:: javascript\n\n  // Example: Reboot the device only if it hasn't been rebooted in the past 300 seconds\n  declare(\"Reboot\", null, {value: Date.now() - (300 * 1000)});\n\n``FactoryReset``\n~~~~~~~~~~~~~~~~\n\nWorks like ``Reboot`` parameter but for factory reset.\n\n.. code:: javascript\n\n  // Example: Default the device to factory settings\n  declare(\"FactoryReset\", null, {value: Date.now()});\n\n``Downloads``\n~~~~~~~~~~~~~\n\nThe ``Downloads`` sub-tree holds information about the last download\ncommand(s). A download command is represented as an instance (e.g.\n``Downloads.1``) containing parameters such as ``Download`` (timestamp),\n``LastFileType``, ``LastFileName``. The parameters ``FileType``, ``FileName``,\n``TargetFileName`` and ``Download`` are writable and can be used to trigger a\nnew download.\n\n.. code:: javascript\n\n  declare(\"Downloads.[FileType:1 Firmware Upgrade Image]\", {path: 1}, {path: 1});\n  declare(\"Downloads.[FileType:1 Firmware Upgrade Image].FileName\", {value: 1}, {value: \"firmware-2017.01.tar\"});\n  declare(\"Downloads.[FileType:1 Firmware Upgrade Image].Download\", {value: 1}, {value: Date.now()});\n\nCommon file types are:\n\n- ``1 Firmware Upgrade Image``\n- ``2 Web Content``\n- ``3 Vendor Configuration File``\n- ``4 Tone File``\n- ``5 Ringer File``\n\n.. warning::\n\n  Pushing a file to the device is often a service-interrupting operation. It's\n  recommended to only trigger it on certain events such as ``1 BOOT`` or during\n  a predetermined maintenance window).\n\nAfter the CPE had finished downloading and applying the config file, it will\nsend a ``7 TRANSFER COMPLETE`` event. You may use that to trigger a reboot\nafter the firmware image or configuration file had been applied.\n"
  },
  {
    "path": "docs/requirements.txt",
    "content": "sphinx_rtd_theme==2.0.0\n"
  },
  {
    "path": "docs/roles-and-permissions.rst",
    "content": ".. _roles-and-permissions:\n\nRoles and Permissions\n=====================\n\nTODO\n"
  },
  {
    "path": "docs/virtual-parameters.rst",
    "content": ".. _virtual-parameters:\n\nVirtual Parameters\n==================\n\nVirtual parameters are user-defined parameters whose values are generated using\na custom Javascript code. Virtual parameters behave just like regular\nparameters and appear in the data model under ``VirtualParameters.`` path.\nVirtual parameter names cannot contain a period (``.``).\n\nThe execution environment for virtual parameters is almost identical to that of\nprovisions. See :ref:`provisions` for more details and examples. The only\ndifferences between the scripts of provisions and virtual parameters are:\n\n- You can't pass custom arguments to virtual parameter scripts. Instead, the\n  variable ``args`` will hold the current vparam timestamps and values as well\n  as the declared timestamps and values. Like this:\n\n.. code:: javascript\n\n  // [<declared attr timestamps, declared attr values>, <current attr timestamps>, <current attr values>]\n  [{path: 1559849387191, value: 1559849387191}, {value: [\"new val\", \"xsd:string\"]}, {path: 1559840000000, value: 1559840000000}, {value: [\"cur val\", \"xsd:string\"]}]\n\n- Virtual parameter scripts must return an object containing the attributes of\n  this parameter.\n\n.. note::\n\n  Just like a regular parameter, creating a virtual parameter does not\n  automatically add it to the parameter list for a device. It needs to fetched\n  (manually or via a preset) before you can see it in the data model.\n\nExamples\n--------\n\nUnified MAC parameter across different device models\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: javascript\n\n  // Example: Unified MAC parameter across different device models\n  let m = \"00:00:00:00:00:00\";\n  let d = declare(\"Device.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress\", {value: Date.now()});\n  let igd = declare(\"InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANPPPConnection.*.MACAddress\", {value: Date.now()});\n\n  if (d.size) {\n    for (let p of d) {\n      if (p.value[0]) {\n        m = p.value[0];\n        break;\n      }\n    }  \n  }\n  else if (igd.size) {\n    for (let p of igd) {\n      if (p.value[0]) {\n        m = p.value[0];\n        break;\n      }\n    }  \n  }\n\n  return {writable: false, value: [m, \"xsd:string\"]};\n\nExpose an external value as a virtual parameter\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: javascript\n\n  // Example: Expose an external value as a virtual parameter\n  let serial = declare(\"DeviceID.SerialNumber\", {value: 1});\n  if (args[1].value) {\n    ext(\"example-ext\", \"set\", serial.value[0], args[1].value[0]);\n    return {writable: true, value: [args[1].value[0], \"xsd:string\"]};\n  }\n  else {\n    let v = ext(\"example-ext\", \"get\", serial.value[0]);\n    return {writable: true, value: [v, \"xsd:string\"]};\n  }\n\nCreate an editable virtual parameter for WPA passphrase\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: javascript\n\n  // Example: Create an editable virtual parameter for WPA passphrase\n  let m = \"\";\n  if (args[1].value) {\n    m = args[1].value[0];\n    declare(\"Device.WiFi.AccessPoint.1.Security.KeyPassphrase\", null, {value: m});\n    declare(\"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase\", null, {value: m});\n  }\n  else {\n    let d = declare(\"Device.WiFi.AccessPoint.1.Security.KeyPassphrase\", {value: Date.now()});\n    let igd = declare(\"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase\", {value: Date.now()});\n\n    if (d.size) {\n      m = d.value[0];\n    }\n    else if (igd.size) {\n      m = igd.value[0];  \n    }\n  }\n\n  return {writable: true, value: [m, \"xsd:string\"]};\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import { defineConfig } from \"@eslint/config-helpers\";\nimport eslint from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\nimport eslintConfigPrettier from \"eslint-config-prettier\";\nimport globals from \"globals\";\n\nexport default defineConfig(\n  {\n    ignores: [\"dist/\", \"test/*.js\"],\n  },\n  eslint.configs.recommended,\n  ...tseslint.configs.recommended,\n  eslintConfigPrettier,\n  {\n    languageOptions: {\n      globals: {\n        ...globals.es2022,\n        ...globals.node,\n      },\n      parserOptions: {\n        projectService: true,\n        tsconfigRootDir: import.meta.dirname,\n      },\n    },\n    rules: {\n      \"@typescript-eslint/no-shadow\": [\"error\", { allow: [\"err\"] }],\n      \"handle-callback-err\": \"error\",\n      \"prefer-arrow-callback\": \"error\",\n      \"no-buffer-constructor\": \"error\",\n      \"prefer-const\": [\"error\", { destructuring: \"all\" }],\n      eqeqeq: [\"error\", \"always\", { null: \"ignore\" }],\n      \"@typescript-eslint/no-explicit-any\": \"off\",\n      \"@typescript-eslint/no-use-before-define\": [\n        \"error\",\n        { functions: false },\n      ],\n      \"@typescript-eslint/explicit-function-return-type\": [\n        \"error\",\n        { allowExpressions: true },\n      ],\n      \"@typescript-eslint/no-floating-promises\": \"error\",\n      \"@typescript-eslint/no-misused-promises\": \"error\",\n      \"no-prototype-builtins\": \"off\",\n    },\n  },\n  {\n    files: [\"ui/**/*.ts\", \"ui/**/*.tsx\"],\n    languageOptions: {\n      globals: {\n        ...globals.browser,\n      },\n    },\n  },\n  {\n    files: [\"seed/*.js\"],\n    languageOptions: {\n      parserOptions: {\n        project: null,\n      },\n      globals: {\n        declare: \"readonly\",\n        clear: \"readonly\",\n        commit: \"readonly\",\n        ext: \"readonly\",\n        log: \"readonly\",\n        args: \"readonly\",\n      },\n    },\n    rules: {\n      \"@typescript-eslint/explicit-function-return-type\": \"off\",\n      \"@typescript-eslint/no-floating-promises\": \"off\",\n      \"@typescript-eslint/no-misused-promises\": \"off\",\n      \"@typescript-eslint/no-shadow\": \"off\",\n      \"no-shadow\": [\"error\", { allow: [\"err\", \"total\"] }],\n    },\n  },\n  {\n    files: [\"seed/*.jsx\"],\n    languageOptions: {\n      parserOptions: {\n        project: null,\n      },\n      globals: {\n        ...globals.browser,\n        node: \"readonly\",\n        Signal: \"readonly\",\n      },\n    },\n    rules: {\n      \"@typescript-eslint/explicit-function-return-type\": \"off\",\n      \"@typescript-eslint/no-floating-promises\": \"off\",\n      \"@typescript-eslint/no-misused-promises\": \"off\",\n      \"@typescript-eslint/no-shadow\": \"off\",\n      \"no-shadow\": [\"error\", { allow: [\"err\", \"total\"] }],\n    },\n  },\n);\n"
  },
  {
    "path": "lib/api-functions.ts",
    "content": "import { ObjectId } from \"mongodb\";\nimport { collections } from \"./db/db.ts\";\nimport {\n  deleteConfig,\n  deleteFault as dbDeleteFault,\n  deleteFile,\n  deletePermission,\n  deletePreset,\n  deleteProvision,\n  deleteTask,\n  deleteUser,\n  deleteVirtualParameter,\n  putConfig,\n  putPermission,\n  putPreset,\n  putProvision,\n  putUser,\n  putVirtualParameter,\n  putView,\n  deleteView,\n} from \"./ui/db.ts\";\nimport * as common from \"./util.ts\";\nimport * as cache from \"./cache.ts\";\nimport { acquireLock, getToken, releaseLock } from \"./lock.ts\";\nimport {\n  getRevision,\n  getConfig,\n  getConfigExpression,\n  getUsers,\n} from \"./ui/local-cache.ts\";\nimport {\n  httpConnectionRequest,\n  udpConnectionRequest,\n  xmppConnectionRequest,\n} from \"./connection-request.ts\";\nimport { Task } from \"./types.ts\";\nimport Expression, { Value } from \"./common/expression.ts\";\nimport { hashPassword } from \"./auth.ts\";\nimport { flattenDevice } from \"./ui/db.ts\";\nimport { ResourceLockedError } from \"./common/errors.ts\";\nimport * as config from \"../lib/config.ts\";\n\nconst XMPP_CONFIGURED = !!config.get(\"XMPP_JID\");\n\nexport async function connectionRequest(\n  deviceId: string,\n  device?: Record<string, Value>,\n): Promise<string> {\n  if (!device) {\n    const res = await collections.devices.findOne({ _id: deviceId });\n    if (!res) throw new Error(\"No such device\");\n    device = flattenDevice(res);\n  }\n\n  let connectionRequestUrl,\n    udpConnectionRequestAddress,\n    stunEnable,\n    connReqJabberId,\n    username,\n    password;\n\n  if (device[\"InternetGatewayDevice.ManagementServer.ConnectionRequestURL\"]) {\n    connectionRequestUrl =\n      device[\"InternetGatewayDevice.ManagementServer.ConnectionRequestURL\"] ||\n      \"\";\n    udpConnectionRequestAddress =\n      device[\n        \"InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress\"\n      ] || \"\";\n    stunEnable =\n      device[\"InternetGatewayDevice.ManagementServer.STUNEnable\"] || \"\";\n    connReqJabberId =\n      device[\"InternetGatewayDevice.ManagementServer.ConnReqJabberID\"] || \"\";\n    username =\n      device[\n        \"InternetGatewayDevice.ManagementServer.ConnectionRequestUsername\"\n      ] || \"\";\n    password =\n      device[\n        \"InternetGatewayDevice.ManagementServer.ConnectionRequestPassword\"\n      ] || \"\";\n  } else {\n    connectionRequestUrl =\n      device[\"Device.ManagementServer.ConnectionRequestURL\"] || \"\";\n    udpConnectionRequestAddress =\n      device[\"Device.ManagementServer.UDPConnectionRequestAddress\"] || \"\";\n    stunEnable = device[\"Device.ManagementServer.STUNEnable\"] || \"\";\n    connReqJabberId = device[\"Device.ManagementServer.ConnReqJabberID\"] || \"\";\n    username =\n      device[\"Device.ManagementServer.ConnectionRequestUsername\"] || \"\";\n    password =\n      device[\"Device.ManagementServer.ConnectionRequestPassword\"] || \"\";\n  }\n  let remoteAddress;\n  try {\n    remoteAddress = new URL(connectionRequestUrl).hostname;\n  } catch {\n    return \"Invalid connection request URL\";\n  }\n\n  const snapshot = await getRevision();\n  const now = Date.now();\n\n  const evalCallback = (exp: Expression): Expression => {\n    if (exp instanceof Expression.Parameter) {\n      let name = exp.path.toString();\n      if (name === \"id\") name = \"DeviceID.ID\";\n      else if (name === \"serialNumber\") name = \"DeviceID.SerialNumber\";\n      else if (name === \"productClass\") name = \"DeviceID.ProductClass\";\n      else if (name === \"oui\") name = \"DeviceID.OUI\";\n      else if (name === \"remoteAddress\")\n        return new Expression.Literal(remoteAddress);\n      else if (name === \"username\") return new Expression.Literal(username);\n      else if (name === \"password\") return new Expression.Literal(password);\n      return new Expression.Literal(device[name] ?? null);\n    } else if (exp instanceof Expression.FunctionCall) {\n      if (exp.name === \"NOW\") return new Expression.Literal(now);\n      else if (exp.name === \"REMOTE_ADDRESS\")\n        return new Expression.Literal(remoteAddress);\n      else if (exp.name === \"USERNAME\") return new Expression.Literal(username);\n      else if (exp.name === \"PASSWORD\") return new Expression.Literal(password);\n    }\n    return exp;\n  };\n\n  const configCallback = (exp: Expression): Expression.Literal => {\n    const e = evalCallback(exp);\n    if (e instanceof Expression.Literal) return e;\n    return new Expression.Literal(null);\n  };\n\n  const UDP_CONNECTION_REQUEST_PORT = getConfig(\n    snapshot,\n    \"cwmp.udpConnectionRequestPort\",\n    0,\n    configCallback,\n  );\n  const CONNECTION_REQUEST_TIMEOUT = getConfig(\n    snapshot,\n    \"cwmp.connectionRequestTimeout\",\n    2000,\n    configCallback,\n  );\n  const CONNECTION_REQUEST_ALLOW_BASIC_AUTH = getConfig(\n    snapshot,\n    \"cwmp.connectionRequestAllowBasicAuth\",\n    false,\n    configCallback,\n  );\n  let authExp: Expression = getConfigExpression(\n    snapshot,\n    \"cwmp.connectionRequestAuth\",\n  );\n\n  if (!authExp) {\n    authExp = new Expression.FunctionCall(\"AUTH\", [\n      new Expression.FunctionCall(\"USERNAME\", []),\n      new Expression.FunctionCall(\"PASSWORD\", []),\n    ]);\n  }\n\n  authExp = authExp.evaluate(evalCallback);\n\n  const debug = getConfig(snapshot, \"cwmp.debug\", false, configCallback);\n\n  let udpProm = Promise.resolve(false);\n  if (udpConnectionRequestAddress && +stunEnable) {\n    try {\n      const u = new URL(\"udp://\" + udpConnectionRequestAddress);\n      udpProm = udpConnectionRequest(\n        u.hostname,\n        parseInt(u.port || \"80\"),\n        authExp,\n        UDP_CONNECTION_REQUEST_PORT,\n        debug,\n        deviceId,\n      ).then(\n        () => true,\n        () => false,\n      );\n    } catch {\n      // Ignore invalid address\n    }\n  }\n\n  let status;\n\n  if (connReqJabberId && XMPP_CONFIGURED) {\n    status = await xmppConnectionRequest(\n      connReqJabberId,\n      authExp,\n      CONNECTION_REQUEST_TIMEOUT,\n      debug,\n      deviceId,\n    );\n  } else {\n    status = await httpConnectionRequest(\n      connectionRequestUrl,\n      authExp,\n      CONNECTION_REQUEST_ALLOW_BASIC_AUTH,\n      CONNECTION_REQUEST_TIMEOUT,\n      debug,\n      deviceId,\n    );\n  }\n\n  if (await udpProm) return \"\";\n\n  return status;\n}\n\nexport async function awaitSessionStart(\n  deviceId: string,\n  lastInform: number,\n  timeout: number,\n): Promise<boolean> {\n  const now = Date.now();\n  const device = await collections.devices.findOne(\n    { _id: deviceId },\n    { projection: { _lastInform: 1 } },\n  );\n  const li = (device[\"_lastInform\"] as Date).getTime();\n  if (li > lastInform) return true;\n  const token = await getToken(`cwmp_session_${deviceId}`);\n  if (token?.startsWith(\"cwmp_session_\")) return true;\n  if (timeout < 500) return false;\n  await new Promise((resolve) => setTimeout(resolve, 500));\n  timeout -= Date.now() - now;\n  return awaitSessionStart(deviceId, lastInform, timeout);\n}\n\nexport async function awaitSessionEnd(\n  deviceId: string,\n  timeout: number,\n): Promise<boolean> {\n  const now = Date.now();\n  const token = await getToken(`cwmp_session_${deviceId}`);\n  if (!token?.startsWith(\"cwmp_session_\")) return true;\n  if (timeout < 500) return false;\n  await new Promise((resolve) => setTimeout(resolve, 500));\n  timeout -= Date.now() - now;\n  return awaitSessionEnd(deviceId, timeout);\n}\n\nfunction sanitizeTask(task): void {\n  task.timestamp = new Date(task.timestamp || Date.now());\n  if (task.expiry) {\n    if (task.expiry instanceof Date || isNaN(task.expiry))\n      task.expiry = new Date(task.expiry);\n    else task.expiry = new Date(task.timestamp.getTime() + +task.expiry * 1000);\n  }\n\n  const validParamValue = (p): boolean => {\n    if (\n      !Array.isArray(p) ||\n      p.length < 2 ||\n      typeof p[0] !== \"string\" ||\n      !p[0].length ||\n      ![\"string\", \"boolean\", \"number\"].includes(typeof p[1]) ||\n      (p[2] != null && typeof p[2] !== \"string\")\n    )\n      return false;\n    return true;\n  };\n\n  switch (task.name) {\n    case \"getParameterValues\":\n      if (!Array.isArray(task.parameterNames) || !task.parameterNames.length)\n        throw new Error(\"Missing 'parameterNames' property\");\n      for (const p of task.parameterNames) {\n        if (typeof p !== \"string\" || !p.length)\n          throw new Error(`Invalid parameter name '${p}'`);\n      }\n      break;\n\n    case \"setParameterValues\":\n      if (!Array.isArray(task.parameterValues) || !task.parameterValues.length)\n        throw new Error(\"Missing 'parameterValues' property\");\n      for (const p of task.parameterValues) {\n        if (!validParamValue(p))\n          throw new Error(`Invalid parameter value '${p}'`);\n      }\n      break;\n\n    case \"refreshObject\":\n      if (typeof task.objectName !== \"string\")\n        throw new Error(\"Missing 'objectName' property\");\n      break;\n\n    case \"deleteObject\":\n      if (typeof task.objectName !== \"string\" || !task.objectName.length)\n        throw new Error(\"Missing 'objectName' property\");\n      break;\n\n    case \"addObject\":\n      if (task.parameterValues != null) {\n        if (!Array.isArray(task.parameterValues))\n          throw new Error(\"Invalid 'parameterValues' property\");\n        for (const p of task.parameterValues) {\n          if (!validParamValue(p))\n            throw new Error(`Invalid parameter value '${p}'`);\n        }\n      }\n      break;\n\n    case \"download\":\n      // genieacs-gui sends file ID instead of fileName and fileType\n      if (!task.file) {\n        if (typeof task.fileType !== \"string\" || !task.fileType.length)\n          throw new Error(\"Missing 'fileType' property\");\n\n        if (typeof task.fileName !== \"string\" || !task.fileName.length)\n          throw new Error(\"Missing 'fileName' property\");\n      }\n\n      if (\n        task.targetFileName != null &&\n        typeof task.targetFileName !== \"string\"\n      )\n        throw new Error(\"Invalid 'targetFileName' property\");\n      break;\n\n    case \"provisions\":\n      if (\n        !Array.isArray(task.provisions) ||\n        !task.provisions.every((arr) =>\n          arr.every(\n            (s) =>\n              s == null || [\"boolean\", \"number\", \"string\"].includes(typeof s),\n          ),\n        )\n      )\n        throw new Error(\"Invalid 'provisions' property\");\n      break;\n\n    case \"reboot\":\n      break;\n\n    case \"factoryReset\":\n      break;\n\n    default:\n      throw new Error(\"Invalid task name\");\n  }\n\n  return task;\n}\n\nexport async function insertTasks(tasks: any[]): Promise<Task[]> {\n  if (tasks && !Array.isArray(tasks)) tasks = [tasks];\n  else if (!tasks?.length) return tasks || [];\n\n  for (const task of tasks) {\n    sanitizeTask(task);\n    if (task.uniqueKey) {\n      await collections.tasks.deleteOne({\n        device: task.device,\n        uniqueKey: task.uniqueKey,\n      });\n    }\n  }\n  await collections.tasks.insertMany(tasks);\n  for (const task of tasks) task._id = task._id.toString();\n  return tasks;\n}\n\nexport async function deleteDevice(deviceId: string): Promise<void> {\n  const token = await acquireLock(`cwmp_session_${deviceId}`, 5000);\n  if (!token) throw new ResourceLockedError(\"Device is in session\");\n  try {\n    await Promise.all([\n      collections.tasks.deleteMany({ device: deviceId }),\n      collections.devices.deleteOne({ _id: deviceId }),\n      collections.faults.deleteMany({\n        _id: {\n          $regex: `^${common.escapeRegExp(deviceId)}\\\\:`,\n        },\n      }),\n      collections.operations.deleteMany({\n        _id: {\n          $regex: `^${common.escapeRegExp(deviceId)}\\\\:`,\n        },\n      }),\n    ]);\n  } finally {\n    await releaseLock(`cwmp_session_${deviceId}`, token);\n  }\n}\n\nexport async function deleteFault(id: string): Promise<void> {\n  const deviceId = id.split(\":\", 1)[0];\n  const channel = id.slice(deviceId.length + 1);\n  const token = await acquireLock(`cwmp_session_${deviceId}`, 5000);\n  if (!token) throw new ResourceLockedError(\"Device is in session\");\n  try {\n    const proms = [dbDeleteFault(id)];\n    if (channel.startsWith(\"task_\"))\n      proms.push(deleteTask(new ObjectId(channel.slice(5))));\n    await Promise.all(proms);\n  } finally {\n    await releaseLock(`cwmp_session_${deviceId}`, token);\n  }\n}\n\nexport async function deleteResource(\n  resource: string,\n  id: string,\n): Promise<void> {\n  if (resource === \"devices\") {\n    await deleteDevice(id);\n  } else if (resource === \"files\") {\n    await deleteFile(id);\n    await cache.del(\"cwmp-local-cache-hash\");\n  } else if (resource === \"faults\") {\n    await deleteFault(id);\n  } else if (resource === \"provisions\") {\n    await deleteProvision(id);\n    await cache.del(\"cwmp-local-cache-hash\");\n  } else if (resource === \"presets\") {\n    await deletePreset(id);\n    await cache.del(\"cwmp-local-cache-hash\");\n  } else if (resource === \"virtualParameters\") {\n    await deleteVirtualParameter(id);\n    await cache.del(\"cwmp-local-cache-hash\");\n  } else if (resource === \"config\") {\n    await deleteConfig(id);\n    await Promise.all([\n      cache.del(\"ui-local-cache-hash\"),\n      cache.del(\"cwmp-local-cache-hash\"),\n    ]);\n  } else if (resource === \"permissions\") {\n    await deletePermission(id);\n    await cache.del(\"ui-local-cache-hash\");\n  } else if (resource === \"users\") {\n    await deleteUser(id);\n    await cache.del(\"ui-local-cache-hash\");\n  } else if (resource === \"views\") {\n    await deleteView(id);\n    await cache.del(\"ui-local-cache-hash\");\n  } else {\n    throw new Error(`Unknown resource ${resource}`);\n  }\n}\n\n// TODO Implement validation\nexport async function putResource(\n  resource: string,\n  id: string,\n  data: any,\n): Promise<void> {\n  if (resource === \"presets\") {\n    await putPreset(id, data);\n    await cache.del(\"cwmp-local-cache-hash\");\n  } else if (resource === \"provisions\") {\n    await putProvision(id, data);\n    await cache.del(\"cwmp-local-cache-hash\");\n  } else if (resource === \"virtualParameters\") {\n    await putVirtualParameter(id, data);\n    await cache.del(\"cwmp-local-cache-hash\");\n  } else if (resource === \"config\") {\n    await putConfig(id, data);\n    await Promise.all([\n      cache.del(\"ui-local-cache-hash\"),\n      cache.del(\"cwmp-local-cache-hash\"),\n    ]);\n  } else if (resource === \"permissions\") {\n    await putPermission(id, data);\n    await cache.del(\"ui-local-cache-hash\");\n  } else if (resource === \"users\") {\n    delete data[\"password\"];\n    delete data[\"salt\"];\n    await putUser(id, data);\n    await cache.del(\"ui-local-cache-hash\");\n  } else if (resource === \"views\") {\n    await putView(id, data);\n    await cache.del(\"ui-local-cache-hash\");\n  } else {\n    throw new Error(`Unknown resource ${resource}`);\n  }\n}\n\nexport function authLocal(\n  snapshot: string,\n  username: string,\n  password: string,\n): Promise<boolean> {\n  return new Promise((resolve, reject) => {\n    const users = getUsers(snapshot);\n    const user = users[username];\n    if (!user?.password) return void resolve(null);\n    hashPassword(password, user.salt)\n      .then((hash) => {\n        if (hash === user.password) resolve(true);\n        else resolve(false);\n      })\n      .catch(reject);\n  });\n}\n"
  },
  {
    "path": "lib/auth.ts",
    "content": "import { createHash, randomBytes, pbkdf2 } from \"node:crypto\";\n\nfunction parseHeaderFeilds(str: string): Record<string, string> {\n  const res = {};\n  const parts = str.split(\",\");\n\n  let part: string;\n  while ((part = parts.shift()) != null) {\n    const name = part.split(\"=\", 1)[0];\n    if (name.length === part.length) {\n      if (!part.trim()) continue;\n      throw new Error(\"Unable to parse auth header\");\n    }\n\n    let value = part.slice(name.length + 1);\n    if (!/^\\s*\"/.test(value)) {\n      value = value.trim();\n    } else {\n      while (!/[^\\\\]\"\\s*$/.test(value)) {\n        const p = parts.shift();\n        if (p == null) throw new Error(\"Unable to parse auth header\");\n        value += \",\" + p;\n      }\n\n      try {\n        value = JSON.parse(value);\n      } catch (error) {\n        throw new Error(\"Unable to parse auth header\", { cause: error });\n      }\n    }\n    res[name.trim()] = value;\n  }\n  return res;\n}\n\nexport function parseAuthorizationHeader(authHeader: string): {\n  method: string;\n} {\n  authHeader = authHeader.trim();\n  const method = authHeader.split(\" \", 1)[0];\n  const res = { method: method };\n\n  if (method === \"Basic\") {\n    // Inspired by https://github.com/jshttp/basic-auth\n    const USER_PASS_REGEX = /^([^:]*):(.*)$/;\n    const creds = USER_PASS_REGEX.exec(\n      Buffer.from(authHeader.slice(method.length + 1), \"base64\").toString(),\n    );\n\n    if (!creds) throw new Error(\"Unable to parse auth header\");\n    res[\"username\"] = creds[1];\n    res[\"password\"] = creds[2];\n  } else if (method === \"Digest\") {\n    Object.assign(res, parseHeaderFeilds(authHeader.slice(method.length + 1)));\n  }\n\n  return res;\n}\n\nexport function parseWwwAuthenticateHeader(\n  authHeader: string,\n): Record<string, string> {\n  authHeader = authHeader.trim();\n  const method = authHeader.split(\" \", 1)[0];\n  const res = { method: method };\n  Object.assign(res, parseHeaderFeilds(authHeader.slice(method.length + 1)));\n  return res;\n}\n\nexport function basic(username: string, password: string): string {\n  return \"Basic \" + Buffer.from(`${username}:${password}`).toString(\"base64\");\n}\n\nexport function digest(\n  username: string | Buffer,\n  realm: string | Buffer,\n  password: string | Buffer,\n  nonce: string | Buffer,\n  httpMethod: string | Buffer,\n  uri: string | Buffer,\n  qop?: string | Buffer,\n  body?: string | Buffer,\n  cnonce?: string | Buffer,\n  nc?: string | Buffer,\n): string {\n  const ha1 = createHash(\"md5\");\n  ha1.update(username).update(\":\").update(realm).update(\":\").update(password);\n  // TODO support \"MD5-sess\" algorithm directive\n  const ha1d = ha1.digest(\"hex\");\n\n  const ha2 = createHash(\"md5\");\n  ha2.update(httpMethod).update(\":\").update(uri);\n\n  if (qop === \"auth-int\") {\n    const bodyHash = createHash(\"md5\")\n      .update(body || \"\")\n      .digest(\"hex\");\n    ha2.update(\":\").update(bodyHash);\n  }\n\n  const ha2d = ha2.digest(\"hex\");\n\n  const hash = createHash(\"md5\");\n  hash.update(ha1d).update(\":\").update(nonce);\n  if (qop) {\n    hash\n      .update(\":\")\n      .update(nc)\n      .update(\":\")\n      .update(cnonce)\n      .update(\":\")\n      .update(qop);\n  }\n  hash.update(\":\").update(ha2d);\n\n  return hash.digest(\"hex\");\n}\n\nexport function solveDigest(\n  username: string | Buffer,\n  password: string | Buffer,\n  uri: string | Buffer,\n  httpMethod: string | Buffer,\n  body: string | Buffer,\n  authHeader: Record<string, string>,\n): string {\n  const cnonce = randomBytes(8).toString(\"hex\");\n  const nc = \"00000001\";\n\n  let qop;\n  if (authHeader.qop) {\n    if (authHeader.qop.indexOf(\",\") !== -1) qop = \"auth\";\n    // Either auth or auth-int, prefer auth\n    else qop = authHeader.qop;\n  }\n\n  const hash = digest(\n    username,\n    authHeader.realm,\n    password,\n    authHeader.nonce,\n    httpMethod,\n    uri,\n    qop,\n    body,\n    cnonce,\n    nc,\n  );\n\n  let authString = `Digest username=\"${username}\"`;\n  authString += `,realm=\"${authHeader.realm}\"`;\n  authString += `,nonce=\"${authHeader.nonce}\"`;\n  authString += `,uri=\"${uri}\"`;\n  if (authHeader.algorithm) authString += `,algorithm=${authHeader.algorithm}`;\n  if (qop) authString += `,qop=${qop},nc=${nc},cnonce=\"${cnonce}\"`;\n  authString += `,response=\"${hash}\"`;\n  if (authHeader.opaque) authString += `,opaque=\"${authHeader.opaque}\"`;\n\n  return authString;\n}\n\nexport function generateSalt(length: number): Promise<string> {\n  return new Promise((resolve, reject) => {\n    randomBytes(length, (err, rand) => {\n      if (err) return void reject(err);\n      resolve(rand.toString(\"hex\"));\n    });\n  });\n}\n\nexport function hashPassword(pass: string, salt: string): Promise<string> {\n  return new Promise((resolve, reject) => {\n    pbkdf2(pass, salt, 10000, 128, \"sha512\", (err, hash) => {\n      if (err) return void reject(err);\n      resolve(hash.toString(\"hex\"));\n    });\n  });\n}\n"
  },
  {
    "path": "lib/bundle-views.ts",
    "content": "import esbuild from \"esbuild\";\n\nimport { APP_JS } from \"../build/assets.ts\";\nimport { Views } from \"./types.ts\";\n\nexport async function validateViewScript(\n  id: string,\n  script: string,\n): Promise<string | null> {\n  const input = buildInput({ [id]: { md5: \"\", script } } as unknown as Views);\n  try {\n    await runBuild(input);\n  } catch (err) {\n    if (!err.errors?.length) throw err;\n    const e = err.errors[0];\n    if (!e.location) return e.text;\n    const offset = input\n      .slice(0, input.indexOf(\"function(node,\"))\n      .split(\"\\n\").length;\n    const line = e.location.line - offset;\n    return `${e.text} at ${id}:${line}:${e.location.column}`;\n  }\n  return null;\n}\n\nfunction buildInput(views: Views): string {\n  const appJsPath = `./${APP_JS}`;\n  const viewEntries: string[] = [];\n  for (const [k, v] of Object.entries(views)) {\n    viewEntries.push(`\n      \"${k}\": function(node, setTimeout, setInterval, Date) {\n        ${v.script}\n      }`);\n  }\n\n  return `\n    import {ViewNode, Signal} from \"${appJsPath}\";\n\n    function h(name, attributes, ...children) {\n      return new ViewNode(name, attributes, children.flat());\n    }\n\n    export default {\n      ${viewEntries.join(\",\\n\")}\n    };\n  `;\n}\n\nexport async function bundleViews(views: Views): Promise<string> {\n  return runBuild(buildInput(views));\n}\n\nasync function runBuild(input: string): Promise<string> {\n  const appJsPath = `./${APP_JS}`;\n\n  const buildResult = await esbuild.build({\n    stdin: {\n      contents: input,\n      loader: \"jsx\",\n    },\n    bundle: true,\n    write: false,\n    format: \"esm\",\n    logLevel: \"silent\",\n    minify: process.env.NODE_ENV === \"production\",\n    jsxFactory: \"h\",\n    jsxFragment: \"null\",\n    plugins: [\n      {\n        name: \"import-resolver\",\n        setup(build) {\n          build.onResolve({ filter: /.*/ }, (args) => {\n            if (args.path === appJsPath)\n              return { sideEffects: false, external: true };\n            return { path: args.path, namespace: \"env-ns\" };\n          });\n          build.onLoad({ filter: /.*/, namespace: \"env-ns\" }, () => {\n            throw new Error(`import not supported`);\n          });\n        },\n      },\n    ],\n  });\n\n  return buildResult.outputFiles[0].text;\n}\n"
  },
  {
    "path": "lib/cache.ts",
    "content": "import { collections } from \"./db/db.ts\";\nimport * as config from \"./config.ts\";\n\nconst CLOCK_SKEW_TOLERANCE = 30000;\nconst MAX_CACHE_TTL = +config.get(\"MAX_CACHE_TTL\");\n\nexport async function get(key: string): Promise<string> {\n  const res = await collections.cache.findOne({ _id: key });\n  return res?.value;\n}\n\nexport async function del(key: string): Promise<void> {\n  await collections.cache.deleteOne({ _id: key });\n}\n\nexport async function set(\n  key: string,\n  value: string,\n  ttl: number = MAX_CACHE_TTL,\n): Promise<void> {\n  const timestamp = new Date();\n  const expire = new Date(\n    timestamp.getTime() + CLOCK_SKEW_TOLERANCE + ttl * 1000,\n  );\n  await collections.cache.replaceOne(\n    { _id: key },\n    { value, expire, timestamp },\n    { upsert: true },\n  );\n}\n\nexport async function pop(key: string): Promise<string> {\n  const res = await collections.cache.findOneAndDelete({ _id: key });\n  return res.value?.value;\n}\n"
  },
  {
    "path": "lib/cluster.ts",
    "content": "import cluster, { Worker } from \"node:cluster\";\nimport { cpus } from \"node:os\";\nimport * as logger from \"./logger.ts\";\n\nlet respawnTimestamp = 0;\nlet crashes: number[] = [];\n\nfunction fork(): Worker {\n  const w = cluster.fork();\n  w.on(\"error\", (err: NodeJS.ErrnoException) => {\n    // Avoid exception when attempting to kill the process just as it's exiting\n    if (err.code !== \"EPIPE\") throw err;\n    setTimeout(() => {\n      if (!w.isDead()) throw err;\n    }, 50);\n  });\n  return w;\n}\n\nfunction restartWorker(worker, code, signal): void {\n  const msg = {\n    message: \"Worker died\",\n    pid: worker.process.pid,\n    exitCode: null,\n    signal: null,\n  };\n\n  if (code != null) msg.exitCode = code;\n\n  if (signal != null) msg.signal = signal;\n\n  logger.error(msg);\n\n  const now = Date.now();\n  crashes.push(now);\n\n  let min1 = 0,\n    min2 = 0,\n    min3 = 0;\n\n  crashes = crashes.filter((n) => {\n    if (n > now - 60000) ++min1;\n    else if (n > now - 120000) ++min2;\n    else if (n > now - 180000) ++min3;\n    else return false;\n    return true;\n  });\n\n  if (min1 > 5 && min2 > 5 && min3 > 5) {\n    process.exitCode = 1;\n    cluster.removeListener(\"exit\", restartWorker);\n    for (const pid in cluster.workers) cluster.workers[pid].kill();\n\n    logger.error({\n      message: \"Too many crashes, exiting\",\n      pid: process.pid,\n    });\n    return;\n  }\n\n  respawnTimestamp = Math.max(now, respawnTimestamp + 2000);\n  if (respawnTimestamp === now) {\n    fork();\n    return;\n  }\n\n  setTimeout(() => {\n    if (process.exitCode) return;\n    fork();\n  }, respawnTimestamp - now);\n}\n\nexport function start(\n  workerCount: number,\n  servicePort: number,\n  serviceAddress: string,\n): void {\n  cluster.on(\"listening\", (worker, address) => {\n    if (\n      (address.addressType === 4 || address.addressType === 6) &&\n      address.address === serviceAddress &&\n      address.port === servicePort\n    ) {\n      logger.info({\n        message: \"Worker listening\",\n        pid: worker.process.pid,\n        address: address.address,\n        port: address.port,\n      });\n    }\n  });\n\n  cluster.on(\"exit\", restartWorker);\n\n  if (!workerCount) workerCount = Math.max(2, cpus().length);\n\n  for (let i = 0; i < workerCount; ++i) fork();\n}\n\nexport function stop(): void {\n  cluster.removeListener(\"exit\", restartWorker);\n  for (const pid in cluster.workers) cluster.workers[pid].kill();\n}\n\nexport const worker = cluster.worker;\n"
  },
  {
    "path": "lib/common/authorizer.ts",
    "content": "import { PermissionSet } from \"../types.ts\";\nimport Expression from \"./expression.ts\";\n\nexport default class Authorizer {\n  declare private permissionSets: PermissionSet[];\n  declare private validatorCache: WeakMap<\n    any,\n    (mutationType, mutation, any) => boolean\n  >;\n  declare private hasAccessCache: Map<string, boolean>;\n  declare private getFilterCache: Map<string, Expression>;\n\n  public constructor(permissionSets: PermissionSet[]) {\n    this.permissionSets = permissionSets;\n    this.validatorCache = new WeakMap();\n    this.hasAccessCache = new Map();\n    this.getFilterCache = new Map();\n  }\n\n  public hasAccess(resourceType: string, access: number): boolean {\n    const cacheKey = `${resourceType}-${access}`;\n    if (this.hasAccessCache.has(cacheKey))\n      return this.hasAccessCache.get(cacheKey);\n\n    let has = false;\n    for (const permissionSet of this.permissionSets) {\n      for (const perm of permissionSet) {\n        if (perm[resourceType]) {\n          if (perm[resourceType].access >= access) {\n            has = true;\n            break;\n          }\n        }\n      }\n    }\n\n    this.hasAccessCache.set(cacheKey, has);\n    return has;\n  }\n\n  public getFilter(resourceType: string, access: number): Expression {\n    const cacheKey = `${resourceType}-${access}`;\n    if (this.getFilterCache.has(cacheKey))\n      return this.getFilterCache.get(cacheKey);\n\n    let filter: Expression = new Expression.Literal(false);\n    for (const permissionSet of this.permissionSets) {\n      for (const perm of permissionSet) {\n        if (perm[resourceType]) {\n          if (perm[resourceType].access >= access)\n            filter = Expression.or(filter, perm[resourceType].filter);\n        }\n      }\n    }\n\n    this.getFilterCache.set(cacheKey, filter);\n    return filter;\n  }\n\n  public getValidator(\n    resourceType: string,\n    resource: unknown,\n  ): (mutationType: string, mutation?: any, args?: any) => boolean {\n    if (this.validatorCache.has(resource))\n      return this.validatorCache.get(resource);\n\n    let validators: Expression = new Expression.Literal(false);\n\n    for (const permissionSet of this.permissionSets) {\n      for (const perm of permissionSet) {\n        if (\n          perm[resourceType] &&\n          perm[resourceType].access >= 3 &&\n          perm[resourceType].validate\n        )\n          validators = Expression.or(validators, perm[resourceType].validate);\n      }\n    }\n\n    const validator = (\n      mutationType: string,\n      mutation: any,\n      any: any,\n    ): boolean => {\n      const object = {\n        mutationType,\n        mutation,\n        resourceType,\n        object: resource,\n        options: any,\n      };\n\n      const now = Date.now();\n      const res = validators.evaluate((exp) => {\n        if (exp instanceof Expression.Literal) return exp;\n        if (exp instanceof Expression.Parameter) {\n          if (exp.path.colon) return new Expression.Literal(null);\n          const entry = exp.path.segments[0] as string;\n          const paramName = exp.path.slice(1);\n          let value = null;\n          if ([\"mutation\", \"options\"].includes(entry)) {\n            value = object[entry];\n            for (const seg of paramName.segments) {\n              if (value == null) break;\n              if (typeof value !== \"object\") value = null;\n              else value = value[seg as string];\n            }\n          } else if (object[entry]) {\n            if (paramName.length) value = object[entry][paramName.toString()];\n            else value = object[entry];\n          }\n          return new Expression.Literal(value);\n        } else if (exp instanceof Expression.FunctionCall) {\n          if (exp.name === \"NOW\") return new Expression.Literal(now);\n        }\n        return new Expression.Literal(null);\n      }).value;\n\n      return !!res;\n    };\n\n    this.validatorCache.set(resource, validator);\n    return validator;\n  }\n\n  public getPermissionSets(): PermissionSet[] {\n    return this.permissionSets;\n  }\n}\n"
  },
  {
    "path": "lib/common/debounce.ts",
    "content": "export default function debounce<T>(\n  func: (args: T[]) => void,\n  timeout: number,\n): (arg: T) => void {\n  let timer: ReturnType<typeof setTimeout>;\n  let args: T[] = [];\n  return (arg: T) => {\n    args.push(arg);\n    clearTimeout(timer);\n    timer = setTimeout(() => {\n      const argscopy = args;\n      args = [];\n      func(argscopy);\n    }, timeout);\n  };\n}\n"
  },
  {
    "path": "lib/common/errors.ts",
    "content": "export class ResourceLockedError extends Error {\n  constructor(message: string) {\n    super(message);\n    this.name = \"ResourceLockedError\";\n  }\n}\n"
  },
  {
    "path": "lib/common/expression/evaluate.ts",
    "content": "import Expression from \"../expression.ts\";\nimport { likePatternToRegExp } from \"./parser.ts\";\n\nfunction compare(\n  a: boolean | number | string,\n  b: boolean | number | string,\n): number {\n  if (typeof a === \"boolean\") a = +a;\n  if (typeof b === \"boolean\") b = +b;\n  if (typeof a !== typeof b) return typeof a === \"string\" ? 1 : -1;\n  return a > b ? 1 : a < b ? -1 : 0;\n}\n\nfunction toNumber(a: boolean | number | string): number {\n  switch (typeof a) {\n    case \"number\":\n      return a;\n    case \"boolean\":\n      return +a;\n    case \"string\":\n      return parseFloat(a) || 0;\n  }\n}\n\nfunction toString(a: boolean | number | string): string {\n  switch (typeof a) {\n    case \"string\":\n      return a;\n    case \"number\":\n      return a.toString();\n    case \"boolean\":\n      return (+a).toString();\n  }\n}\n\nconst regExpCache: WeakMap<Expression.Literal, RegExp> = new WeakMap();\n\nexport function reduce(exp: Expression): Expression {\n  if (exp instanceof Expression.Literal) return exp;\n\n  if (exp instanceof Expression.Unary) {\n    if (exp.operator === \"NOT\") {\n      if (exp.operand instanceof Expression.Literal) {\n        if (exp.operand.value == null) return exp.operand;\n        return new Expression.Literal(!exp.operand.value);\n      } else if (exp.operand instanceof Expression.Unary) {\n        if (exp.operand.operator === \"NOT\") return exp.operand.operand;\n      }\n    } else if (exp.operator === \"IS NULL\") {\n      if (exp.operand instanceof Expression.Literal) {\n        return new Expression.Literal(exp.operand.value == null);\n      }\n    } else if (exp.operator === \"IS NOT NULL\") {\n      if (exp.operand instanceof Expression.Literal) {\n        return new Expression.Literal(exp.operand.value != null);\n      }\n    }\n  } else if (exp instanceof Expression.Binary) {\n    if (exp.operator === \"AND\") {\n      return Expression.and(exp.left, exp.right);\n    } else if (exp.operator === \"OR\") {\n      return Expression.or(exp.left, exp.right);\n    } else if ([\"=\", \">\", \"<\", \"<>\", \">=\", \"<=\"].includes(exp.operator)) {\n      if (exp.left instanceof Expression.Literal && exp.left.value == null)\n        return exp.left;\n      if (exp.right instanceof Expression.Literal && exp.right.value == null)\n        return exp.right;\n      if (\n        exp.left instanceof Expression.Literal &&\n        exp.right instanceof Expression.Literal\n      ) {\n        const c = compare(exp.left.value, exp.right.value);\n        switch (exp.operator) {\n          case \"=\":\n            return new Expression.Literal(c === 0);\n          case \">\":\n            return new Expression.Literal(c > 0);\n          case \"<\":\n            return new Expression.Literal(c < 0);\n          case \"<>\":\n            return new Expression.Literal(c !== 0);\n          case \">=\":\n            return new Expression.Literal(c >= 0);\n          case \"<=\":\n            return new Expression.Literal(c <= 0);\n        }\n      }\n    } else if ([\"+\", \"-\", \"*\", \"/\"].includes(exp.operator)) {\n      if (exp.left instanceof Expression.Literal && exp.left.value == null)\n        return exp.left;\n      if (exp.right instanceof Expression.Literal && exp.right.value == null)\n        return exp.right;\n      if (\n        exp.left instanceof Expression.Literal &&\n        exp.right instanceof Expression.Literal\n      ) {\n        const a = toNumber(exp.left.value);\n        const b = toNumber(exp.right.value);\n        switch (exp.operator) {\n          case \"+\":\n            return new Expression.Literal(a + b);\n          case \"-\":\n            return new Expression.Literal(a - b);\n          case \"*\":\n            return new Expression.Literal(a * b);\n          case \"/\":\n            return new Expression.Literal(a / b);\n        }\n      }\n    } else if (exp.operator === \"%\") {\n      if (exp.left instanceof Expression.Literal && exp.left.value == null)\n        return exp.left;\n      if (exp.right instanceof Expression.Literal && exp.right.value == null)\n        return exp.right;\n      if (\n        exp.left instanceof Expression.Literal &&\n        exp.right instanceof Expression.Literal\n      ) {\n        const a = toNumber(exp.left.value);\n        const b = Math.trunc(toNumber(exp.right.value));\n        if (b === 0) return new Expression.Literal(null);\n        return new Expression.Literal(a % b);\n      }\n    } else if (exp.operator === \"||\") {\n      if (exp.left instanceof Expression.Literal && exp.left.value == null)\n        return exp.left;\n      if (exp.right instanceof Expression.Literal && exp.right.value == null)\n        return exp.right;\n      if (\n        exp.left instanceof Expression.Literal &&\n        exp.right instanceof Expression.Literal\n      ) {\n        const a = toString(exp.left.value);\n        const b = toString(exp.right.value);\n        return new Expression.Literal(a + b);\n      }\n    } else if (exp.operator === \"LIKE\") {\n      if (exp.left instanceof Expression.Literal && exp.left.value == null)\n        return exp.left;\n      if (exp.right instanceof Expression.Literal && exp.right.value == null)\n        return exp.right;\n      if (\n        exp.left instanceof Expression.Literal &&\n        exp.right instanceof Expression.Literal\n      ) {\n        const s = toString(exp.left.value);\n        let r = regExpCache.get(exp.right);\n        if (!r) {\n          r = likePatternToRegExp(toString(exp.right.value));\n          regExpCache.set(exp.right, r);\n        }\n        return new Expression.Literal(r.test(s));\n      }\n    } else if (exp.operator === \"NOT LIKE\") {\n      if (exp.left instanceof Expression.Literal && exp.left.value == null)\n        return exp.left;\n      if (exp.right instanceof Expression.Literal && exp.right.value == null)\n        return exp.right;\n      if (\n        exp.left instanceof Expression.Literal &&\n        exp.right instanceof Expression.Literal\n      ) {\n        const s = toString(exp.left.value);\n        let r = regExpCache.get(exp.right);\n        if (!r) {\n          r = likePatternToRegExp(toString(exp.right.value));\n          regExpCache.set(exp.right, r);\n        }\n        return new Expression.Literal(!r.test(s));\n      }\n    }\n  } else if (exp instanceof Expression.FunctionCall) {\n    if (exp.name === \"COALESCE\") {\n      const args = exp.args.filter(\n        (arg) => !(arg instanceof Expression.Literal && arg.value == null),\n      );\n      if (!args.length) return new Expression.Literal(null);\n      if (args.length === 1) return args[0];\n      if (args[0] instanceof Expression.Literal) return args[0];\n      if (args.length !== exp.args.length)\n        return new Expression.FunctionCall(\"COALESCE\", args);\n    } else if (exp.name === \"UPPER\") {\n      if (exp.args[0] instanceof Expression.Literal) {\n        if (exp.args[0].value == null) return exp.args[0];\n        const a = toString(exp.args[0].value);\n        return new Expression.Literal(a.toUpperCase());\n      }\n    } else if (exp.name === \"LOWER\") {\n      if (exp.args[0] instanceof Expression.Literal) {\n        if (exp.args[0].value == null) return exp.args[0];\n        const a = toString(exp.args[0].value);\n        return new Expression.Literal(a.toLowerCase());\n      }\n    } else if (exp.name === \"ROUND\") {\n      let p = 0;\n      if (exp.args.length > 1) {\n        if (exp.args[1] instanceof Expression.Literal) {\n          if (exp.args[1].value == null) return exp.args[1];\n          p = Math.trunc(toNumber(exp.args[1].value));\n        }\n      }\n      if (exp.args[0] instanceof Expression.Literal) {\n        if (exp.args[0].value == null) return exp.args[0];\n        const n = toNumber(exp.args[0].value);\n        const d = 10 ** p;\n        const m = n * d * (1 + Number.EPSILON);\n        return new Expression.Literal(Math.round(m) / d);\n      }\n    }\n  } else if (exp instanceof Expression.Conditional) {\n    if (exp.condition instanceof Expression.Literal) {\n      if (exp.condition.value) return exp.then;\n      return exp.otherwise;\n    }\n  }\n\n  return exp;\n}\n"
  },
  {
    "path": "lib/common/expression/normalize.ts",
    "content": "import Expression from \"../expression.ts\";\nimport { reduce } from \"./evaluate.ts\";\n\nclass Indeterminates {\n  declare public map: Map<Expression, number>;\n  declare public sortedKeys: Expression[];\n\n  public constructor(exp?: Expression) {\n    this.map = new Map();\n    if (exp) {\n      this.map.set(exp, 1);\n      this.sortedKeys = [exp];\n    } else {\n      this.sortedKeys = [];\n    }\n  }\n\n  public reciprocal(): Indeterminates {\n    const res = new Indeterminates();\n    res.sortedKeys = this.sortedKeys;\n    res.map = new Map();\n    for (const [k, v] of this.map) res.map.set(k, 0 - v);\n    return res;\n  }\n\n  public static multiply(\n    indeterminates1: Indeterminates,\n    indeterminates2: Indeterminates,\n  ): Indeterminates {\n    const res = new Indeterminates();\n    res.sortedKeys = indeterminates1.sortedKeys.slice();\n    res.map = new Map(indeterminates1.map);\n    const strMap: Map<string, Expression> = new Map();\n    for (const k of res.map.keys()) strMap.set(k.toString(), k);\n\n    for (const [key, val] of indeterminates2.map) {\n      const k = strMap.get(key.toString());\n      if (!k) {\n        res.map.set(key, val);\n        res.sortedKeys.push(key);\n      } else {\n        const v2 = val + res.map.get(k);\n        if (!v2) {\n          res.map.delete(k);\n          res.sortedKeys = res.sortedKeys.filter((s) => s !== k);\n        } else {\n          res.map.set(k, v2);\n        }\n      }\n    }\n\n    res.sortedKeys.sort((a, b) => {\n      const str1 = a.toString();\n      const str2 = b.toString();\n      if (str1.length !== str2.length) return str2.length - str1.length;\n      else if (str1 > str2) return 1;\n      else if (str1 < str2) return -1;\n      return 0;\n    });\n\n    return res;\n  }\n\n  public static compare(a: Indeterminates, b: Indeterminates): number {\n    if (a.sortedKeys.length !== b.sortedKeys.length)\n      return b.sortedKeys.length - a.sortedKeys.length;\n    for (let i = 0; i < a.sortedKeys.length; ++i) {\n      const k1 = a.sortedKeys[i];\n      const w1 = a.map.get(k1);\n      const k2 = b.sortedKeys[i];\n      const w2 = b.map.get(k2);\n      if (w1 !== w2) return w2 - w1;\n      if (k1.toString().length > k2.toString().length) return -1;\n      else if (k1.toString().length < k2.toString().length) return 1;\n      else if (k1.toString() > k2.toString()) return 1;\n      else if (k1.toString() < k2.toString()) return -1;\n    }\n    return 0;\n  }\n}\n\ninterface Term {\n  indeterminates: Indeterminates;\n  coefficientNumerator: bigint;\n  coefficientDenominator: bigint;\n}\n\nfunction findGcd(a: bigint, b: bigint): bigint {\n  while (b !== 0n) {\n    const t = b;\n    b = a % b;\n    a = t;\n  }\n  return a;\n}\n\nclass Polynomial extends Expression {\n  declare public terms: Term[];\n\n  public constructor(terms: Term[]) {\n    super();\n    this.terms = terms;\n  }\n\n  map(): Polynomial {\n    return this;\n  }\n\n  async mapAsync(): Promise<Polynomial> {\n    return this;\n  }\n\n  public static simplifyTerms(terms: Term[]): Term[] {\n    const ts = terms\n      .slice()\n      .sort((a: Term, b: Term) =>\n        Indeterminates.compare(a.indeterminates, b.indeterminates),\n      );\n\n    for (let i = 1; i < ts.length; ++i) {\n      const t1 = ts[i - 1];\n      const t2 = ts[i];\n      if (Indeterminates.compare(t1.indeterminates, t2.indeterminates) === 0) {\n        const numerator =\n          t1.coefficientNumerator * t2.coefficientDenominator +\n          t2.coefficientNumerator * t1.coefficientDenominator;\n\n        const denominator =\n          t1.coefficientDenominator * t2.coefficientDenominator;\n\n        const gcd = findGcd(numerator, denominator);\n\n        ts[i] = {\n          indeterminates: t2.indeterminates,\n          coefficientNumerator: numerator / gcd,\n          coefficientDenominator: denominator / gcd,\n        };\n        ts[i - 1] = {\n          indeterminates: t1.indeterminates,\n          coefficientNumerator: 0n,\n          coefficientDenominator: t1.coefficientDenominator,\n        };\n      }\n    }\n    return ts.filter((v) => v.coefficientNumerator !== 0n);\n  }\n\n  public static fromIndeterminate(indeterminate: Expression): Polynomial {\n    const indeterminates = new Indeterminates(indeterminate);\n    const terms = [\n      {\n        indeterminates: indeterminates,\n        coefficientNumerator: 1n,\n        coefficientDenominator: 1n,\n      },\n    ];\n    return new Polynomial(terms);\n  }\n\n  public static fromConstant(constant: number): Polynomial {\n    const [int, frac] = Math.abs(constant).toString(2).split(\".\", 2);\n    let numerator = BigInt(\"0b\" + int);\n    if (constant < 0) numerator = numerator / -1n;\n    let denominator = 1n;\n    if (frac) {\n      denominator = 2n ** BigInt(frac.length);\n      numerator = numerator * denominator + BigInt(\"0b\" + frac);\n    }\n\n    const terms = [\n      {\n        indeterminates: new Indeterminates(),\n        coefficientNumerator: numerator,\n        coefficientDenominator: denominator,\n      },\n    ];\n\n    return new Polynomial(terms);\n  }\n\n  public negation(): Polynomial {\n    const terms = this.terms.map((t) => ({\n      indeterminates: t.indeterminates,\n      coefficientNumerator: t.coefficientNumerator * -1n,\n      coefficientDenominator: t.coefficientDenominator,\n    }));\n\n    return new Polynomial(terms);\n  }\n\n  public reciprocal(): Polynomial {\n    const terms = this.terms.map((t) => ({\n      indeterminates: t.indeterminates.reciprocal(),\n      coefficientNumerator: t.coefficientDenominator,\n      coefficientDenominator: t.coefficientNumerator,\n    }));\n    return new Polynomial(terms);\n  }\n\n  public constant(): Polynomial {\n    const terms = this.terms.filter((t) => !t.indeterminates.sortedKeys.length);\n    return new Polynomial(terms);\n  }\n\n  public add(rhs: Polynomial): Polynomial {\n    return new Polynomial(\n      Polynomial.simplifyTerms(this.terms.concat(rhs.terms)),\n    );\n  }\n\n  public subtract(rhs: Polynomial): Polynomial {\n    return this.add(rhs.negation());\n  }\n\n  public multiply(rhs: Polynomial): Polynomial {\n    const terms: Term[] = [];\n\n    for (const t1 of this.terms) {\n      for (const t2 of rhs.terms) {\n        const numerator = t1.coefficientNumerator * t2.coefficientNumerator;\n        const denominator =\n          t1.coefficientDenominator * t2.coefficientDenominator;\n        const gcd = findGcd(numerator, denominator);\n\n        terms.push({\n          indeterminates: Indeterminates.multiply(\n            t1.indeterminates,\n            t2.indeterminates,\n          ),\n          coefficientNumerator: numerator / gcd,\n          coefficientDenominator: denominator / gcd,\n        });\n      }\n    }\n\n    return new Polynomial(Polynomial.simplifyTerms(terms));\n  }\n\n  public divide(rhs: Polynomial): Polynomial {\n    return this.multiply(rhs.reciprocal());\n  }\n\n  public toExpression(): Expression {\n    const add: Expression[] = [];\n    for (const t of this.terms) {\n      const coefficient =\n        Number(t.coefficientNumerator) / Number(t.coefficientDenominator);\n\n      const mul: Expression[] = [];\n      if (t.indeterminates.sortedKeys.length) {\n        for (const k of t.indeterminates.sortedKeys) {\n          const w = t.indeterminates.map.get(k);\n          for (let i = Math.abs(w); i > 0; --i) {\n            if (w > 0) mul.push(k);\n            else\n              mul.push(\n                new Expression.Binary(\"/\", new Expression.Literal(1), k),\n              );\n          }\n        }\n\n        if (coefficient !== 1) mul.push(new Expression.Literal(coefficient));\n\n        while (mul.length > 1) {\n          const r = mul.pop();\n          const l = mul.pop();\n          mul.push(new Expression.Binary(\"*\", l, r));\n        }\n        add.push(mul[0]);\n      } else {\n        add.push(new Expression.Literal(coefficient));\n      }\n    }\n\n    while (add.length > 1) {\n      const r = add.pop();\n      const l = add.pop();\n      add.push(new Expression.Binary(\"+\", l, r));\n    }\n\n    if (!add.length) return new Expression.Literal(0);\n    return add[0];\n  }\n}\n\nconst SWAPPED_OPS = {\n  \"=\": \"=\",\n  \"<>\": \"<>\",\n  \">\": \"<\",\n  \">=\": \"<=\",\n  \"<\": \">\",\n  \"<=\": \">=\",\n};\n\nfunction cartesianProduct<T>(arrays: T[][]): T[][] {\n  return arrays.reduce<T[][]>(\n    (acc, cur) => acc.flatMap((a) => cur.map((item) => [...a, item])),\n    [[]],\n  );\n}\n\nfunction toPolynomial(e: Expression): Polynomial {\n  if (e instanceof Polynomial) return e;\n  if (e instanceof Expression.Literal) {\n    if (e.value == null) return null;\n    if (typeof e.value === \"number\") return Polynomial.fromConstant(e.value);\n    if (typeof e.value === \"string\")\n      return Polynomial.fromConstant(parseFloat(e.value) || 0);\n    if (typeof e.value === \"boolean\") return Polynomial.fromConstant(+e.value);\n  }\n  return Polynomial.fromIndeterminate(e);\n}\n\nfunction fromPolynomial(e: Expression): Expression {\n  if (e instanceof Polynomial) return e.toExpression();\n  if (e instanceof Expression.Conditional) return e.map(fromPolynomial);\n  return e;\n}\n\nfunction normalizeCallback(exp: Expression): Expression {\n  if (exp instanceof Expression.FunctionCall) {\n    if (exp.name === \"COALESCE\") {\n      let e: Expression = new Expression.Literal(null);\n      for (let i = exp.args.length - 1; i >= 0; --i) {\n        e = new Expression.Conditional(\n          normalizeCallback(new Expression.Unary(\"IS NOT NULL\", exp.args[i])),\n          exp.args[i],\n          e,\n        );\n      }\n      return normalizeCallback(e);\n    }\n  }\n\n  if (exp instanceof Expression.Conditional) {\n    let e = exp;\n\n    if (e.condition instanceof Polynomial)\n      e = new Expression.Conditional(\n        e.condition.toExpression(),\n        e.then,\n        e.otherwise,\n      );\n\n    if (e.then instanceof Expression.Conditional) {\n      e = new Expression.Conditional(\n        Expression.and(e.condition, e.then.condition),\n        e.then.then,\n        new Expression.Conditional(e.condition, e.then.otherwise, e.otherwise),\n      );\n    }\n\n    return e;\n  }\n\n  const combs: [Expression, Expression][][] = [];\n  const callback: (e: Expression) => Expression = (e) => {\n    if (e instanceof Expression.Conditional) {\n      combs[combs.length - 1].push([e.condition, e.then]);\n      callback(e.otherwise);\n    } else {\n      combs[combs.length - 1].push([new Expression.Literal(true), e]);\n    }\n    return e;\n  };\n\n  exp.map((e) => {\n    combs.push([]);\n    callback(e);\n    return e;\n  });\n\n  if (combs.some((a) => a.length > 1)) {\n    let res: Expression = new Expression.Literal(null);\n    for (const p of cartesianProduct(combs).reverse()) {\n      let condition: Expression = new Expression.Literal(true);\n      const e = reduce(\n        normalizeCallback(\n          exp.map((_, i) => {\n            condition = Expression.and(condition, p[i][0]);\n            return p[i][1];\n          }),\n        ),\n      );\n\n      if (!(condition instanceof Expression.Literal))\n        res = new Expression.Conditional(condition, e, res);\n      else if (condition.value) res = e;\n    }\n    return res;\n  }\n\n  if (exp instanceof Expression.Binary) {\n    if ([\"+\", \"-\", \"*\", \"/\"].includes(exp.operator)) {\n      const lhs = toPolynomial(exp.left);\n      const rhs = toPolynomial(exp.right);\n      if (lhs == null || rhs == null) return new Expression.Literal(null);\n      if (exp.operator === \"+\") return lhs.add(rhs);\n      if (exp.operator === \"-\") return lhs.subtract(rhs);\n      if (exp.operator === \"*\") return lhs.multiply(rhs);\n      if (exp.operator === \"/\") return lhs.divide(rhs);\n    } else if ([\">\", \">=\", \"<\", \"<=\", \"=\", \"<>\"].includes(exp.operator)) {\n      let lhs: Polynomial, rhs: Polynomial;\n\n      if (exp.left instanceof Polynomial) lhs = exp.left;\n      else if (exp.left instanceof Expression.Literal) {\n        if (exp.left.value == null) return exp.left;\n        else if (typeof exp.left.value === \"number\")\n          lhs = Polynomial.fromConstant(exp.left.value);\n      }\n\n      if (exp.right instanceof Polynomial) rhs = exp.right;\n      else if (exp.right instanceof Expression.Literal) {\n        if (exp.right.value == null) return exp.right;\n        else if (typeof exp.right.value === \"number\")\n          rhs = Polynomial.fromConstant(exp.right.value);\n      }\n\n      if (lhs || rhs) {\n        if (!lhs) lhs = Polynomial.fromIndeterminate(exp.left);\n        if (!rhs) rhs = Polynomial.fromIndeterminate(exp.right);\n\n        lhs = lhs.subtract(rhs);\n        rhs = lhs.constant().negation();\n        lhs = lhs.add(rhs);\n\n        if (!lhs.terms.length) {\n          const l = lhs.toExpression() as Expression.Literal;\n          const r = rhs.toExpression() as Expression.Literal;\n\n          if (exp.operator === \"=\")\n            return new Expression.Literal(l.value === r.value);\n          else if (exp.operator === \"<>\")\n            return new Expression.Literal(l.value !== r.value);\n          else if (exp.operator === \">\")\n            return new Expression.Literal(l.value > r.value);\n          else if (exp.operator === \">=\")\n            return new Expression.Literal(l.value >= r.value);\n          else if (exp.operator === \"<\")\n            return new Expression.Literal(l.value < r.value);\n          else if (exp.operator === \"<=\")\n            return new Expression.Literal(l.value <= r.value);\n          else throw new Error(\"Invalid operator\");\n        }\n\n        let flipOp = 1;\n\n        const n = lhs.terms[0].coefficientNumerator;\n        const d = lhs.terms[0].coefficientDenominator;\n\n        if (n < 0n || d < 0n) flipOp *= -1;\n\n        const reciprocal = new Polynomial([\n          {\n            indeterminates: new Indeterminates(),\n            coefficientNumerator: d,\n            coefficientDenominator: n,\n          },\n        ]);\n\n        lhs = lhs.multiply(reciprocal);\n        rhs = rhs.multiply(reciprocal);\n\n        const keys = lhs.terms[0].indeterminates.sortedKeys;\n        let invert = lhs.terms[0].indeterminates.map.get(keys[0]) < 0 ? -1 : 0;\n\n        for (const t of lhs.terms)\n          for (const v of t.indeterminates.map.values()) invert += v;\n\n        if (invert < 0) {\n          flipOp *= -1;\n          lhs = lhs.reciprocal();\n          rhs = rhs.reciprocal();\n        }\n\n        if (flipOp > 0) exp = new Expression.Binary(exp.operator, lhs, rhs);\n        else exp = new Expression.Binary(SWAPPED_OPS[exp.operator], lhs, rhs);\n      }\n    }\n  }\n\n  // Restore polynomial expressions\n  exp = exp.map(fromPolynomial);\n\n  return exp;\n}\n\nexport default function normalize(exp: Expression): Expression {\n  return fromPolynomial(exp.evaluate(normalizeCallback));\n}\n"
  },
  {
    "path": "lib/common/expression/pagination.ts",
    "content": "import { complement } from \"espresso-iisojs\";\nimport Expression from \"../expression.ts\";\nimport normalize from \"./normalize.ts\";\nimport { Clause, SynthContext } from \"./synth.ts\";\nimport Path from \"../path.ts\";\n\ntype Bookmark = Record<string, null | boolean | number | string>;\n\ntype Minterm = number[];\n\nexport function toBookmark(\n  sort: Record<string, number>,\n  row: unknown,\n): Bookmark {\n  const bookmark: Bookmark = {};\n  for (const param of Object.keys(sort)) {\n    let v = row[param];\n    if (v != null && typeof v === \"object\") v = v.value?.[0];\n    bookmark[param] = v;\n  }\n  return bookmark;\n}\n\nexport function bookmarkToExpression(\n  bookmark: Bookmark,\n  sort: Record<string, number>,\n): Expression {\n  return Object.entries(sort)\n    .reverse()\n    .reduce(\n      (cur, kv) => {\n        const [param, asc] = kv;\n        const p = new Expression.Parameter(Path.parse(param));\n        const b = new Expression.Literal(bookmark[param]);\n        if (asc < 0) {\n          if (bookmark[param] == null) {\n            return Expression.or(\n              new Expression.Unary(\"IS NOT NULL\", p),\n              Expression.and(new Expression.Unary(\"IS NULL\", p), cur),\n            );\n          }\n          return new Expression.Binary(\n            \"OR\",\n            new Expression.Binary(\">\", p, b),\n            new Expression.Binary(\"AND\", new Expression.Binary(\"=\", p, b), cur),\n          );\n        } else {\n          let f: Expression = new Expression.Unary(\"IS NULL\", p);\n          if (bookmark[param] == null) return Expression.and(f, cur);\n          f = Expression.or(f, new Expression.Binary(\"<\", p, b));\n          return Expression.or(\n            f,\n            Expression.and(new Expression.Binary(\"=\", p, b), cur),\n          );\n        }\n      },\n      new Expression.Literal(true) as Expression,\n    );\n}\n\nfunction getCover(\n  context: SynthContext,\n  minterm: Minterm,\n  allSort: [string, number][],\n): Minterm[] {\n  if (!allSort.length) return [[]];\n  const [param, sort] = allSort[0];\n\n  const cov: Minterm = [];\n  const nextCov: Minterm = [];\n\n  if (sort > 0) {\n    const lhs = new Clause.Exp(new Expression.Parameter(Path.parse(param)));\n    const isNull = context.getVar(new Clause.IsNull(lhs));\n    cov.push((isNull << 2) ^ 2);\n  }\n\n  for (const m of minterm) {\n    const clause = context.getClause(m >>> 2);\n    if (clause instanceof Clause.IsNull) {\n      if (!(clause.operand instanceof Clause.Exp)) continue;\n      if (!(clause.operand.exp instanceof Expression.Parameter)) continue;\n      if (clause.operand.exp.path.toString() !== param) continue;\n      nextCov.push(m);\n      if (sort < 0 && m & 1) cov.push(m, m ^ 1);\n    } else if (clause instanceof Clause.Compare) {\n      if (!(clause.lhs instanceof Clause.Exp)) continue;\n      if (!(clause.lhs.exp instanceof Expression.Parameter)) continue;\n      if (clause.lhs.exp.path.toString() !== param) continue;\n      if (!(m & 1) && sort > 0) continue;\n      nextCov.push(m);\n\n      const negate = (m ^ (m >> 1)) & 1;\n\n      if (sort > 0) {\n        if (\n          (clause.op === \"=\" && !negate) ||\n          (clause.op === \">\" && !negate) ||\n          (clause.op === \"<\" && negate)\n        ) {\n          const c = new Clause.Compare(clause.lhs, \">\", clause.rhs);\n          const v = context.getVar(c);\n          cov.push((v << 2) ^ 3);\n        }\n      } else if (sort < 0) {\n        if (\n          (clause.op === \"=\" && !negate) ||\n          (clause.op === \">\" && negate) ||\n          (clause.op === \"<\" && !negate)\n        ) {\n          const c = new Clause.Compare(clause.lhs, \"<\", clause.rhs);\n          const v = context.getVar(c);\n          cov.push((v << 2) ^ 0);\n        }\n      }\n    }\n  }\n\n  const next = getCover(context, minterm, allSort.slice(1));\n\n  return [cov, ...next.map((n) => [...nextCov, ...n])];\n}\n\nexport function paginate(\n  fetched: Expression,\n  toFetch: Expression,\n  sort: Record<string, number>,\n): [Expression, Expression] {\n  fetched = normalize(fetched);\n  if (fetched instanceof Expression.Literal && !fetched.value)\n    return [new Expression.Literal(false), toFetch];\n\n  toFetch = normalize(toFetch);\n  if (toFetch instanceof Expression.Literal && !toFetch.value)\n    return [new Expression.Literal(false), toFetch];\n\n  const synth1 = Clause.fromExpression(fetched);\n  const synth2 = Clause.fromExpression(toFetch);\n\n  const context = new SynthContext();\n\n  const expr1Minterms = synth1.getMinterms(context, 0b100);\n  const expr2MintermsC = synth2.getMinterms(context, 0b011);\n\n  const gaps = context.sanitizeMinterms(\n    complement([\n      ...expr1Minterms,\n      ...expr2MintermsC,\n      ...context.getDcSet([...expr1Minterms, ...expr2MintermsC]),\n    ]),\n  );\n\n  const cover = gaps.flatMap((m) => getCover(context, m, Object.entries(sort)));\n  const minterms1 = context.minimize(complement([...cover, ...expr2MintermsC]));\n  const minterms2 = context.minimize(\n    complement([...minterms1, ...expr2MintermsC]),\n  );\n\n  return [context.toExpression(minterms1), context.toExpression(minterms2)];\n}\n"
  },
  {
    "path": "lib/common/expression/parser.ts",
    "content": "import Path from \"../path.ts\";\nimport Expression from \"../expression.ts\";\n\nexport class Cursor {\n  input: string;\n  pos: number;\n  boundryStack: number[][];\n  boundry: number[];\n  charCode: number;\n\n  constructor(input: string) {\n    this.input = input;\n    this.pos = 0;\n    this.boundryStack = [];\n    this.boundry = [];\n    this.charCode = input.charCodeAt(0) || 0;\n  }\n\n  fork(): Cursor {\n    const cursor = new Cursor(this.input);\n    cursor.pos = this.pos;\n    cursor.boundryStack = this.boundryStack.slice();\n    cursor.boundry = this.boundry;\n    cursor.charCode = this.charCode;\n    return cursor;\n  }\n\n  sync(cursor: Cursor): void {\n    this.pos = cursor.pos;\n    this.boundryStack = cursor.boundryStack.slice();\n    this.boundry = cursor.boundry;\n    this.charCode = cursor.charCode;\n  }\n\n  read(cur: Cursor): string {\n    return this.input.slice(this.pos, cur.pos);\n  }\n\n  step(): Cursor {\n    if (this.charCode === 0) return this;\n    ++this.pos;\n    this.charCode = this.input.charCodeAt(this.pos) || 0;\n    if (this.boundry.includes(this.charCode)) this.charCode = 0;\n    return this;\n  }\n\n  walk(callback: (charCode: number) => boolean): Cursor {\n    while (this.charCode && callback(this.charCode)) this.step();\n    return this;\n  }\n\n  descend(chars: number[], override = true): void {\n    this.boundryStack.push(this.boundry);\n    if (override) this.boundry = chars;\n    else this.boundry = [...this.boundry, ...chars];\n    this.charCode = this.input.charCodeAt(this.pos) || 0;\n    if (this.boundry.includes(this.charCode)) this.charCode = 0;\n  }\n\n  ascend(): void {\n    if (!this.boundryStack.length) throw new Error(\"Unmatched boundry\");\n    this.boundry = this.boundryStack.pop();\n    this.charCode = this.input.charCodeAt(this.pos) || 0;\n  }\n\n  skipwhitespace(): Cursor {\n    return this.walk((c) => c <= 32);\n  }\n}\n\nconst CHAR_SINGLE_QUOTE = 39;\nconst CHAR_DOUBLE_QUOTE = 34;\nconst CHAR_OPEN_PAREN = 40;\nconst CHAR_CLOSE_PAREN = 41;\nconst CHAR_COMMA = 44;\nconst CHAR_PERIOD = 46;\nconst CHAR_COLON = 58;\nconst CHAR_OPEN_BRACKET = 91;\nconst CHAR_BACKSLASH = 92;\nconst CHAR_CLOSE_BRACKET = 93;\n\nconst BINARY_OPERATORS = [\n  \">=\",\n  \"<=\",\n  \"<>\",\n  \"=\",\n  \">\",\n  \"<\",\n  \"LIKE\",\n  \"NOT LIKE\",\n  \"AND\",\n  \"OR\",\n  \"*\",\n  \"/\",\n  \"%\",\n  \"||\",\n  \"-\",\n  \"+\",\n];\n\nconst PRECEDENCE = {\n  OR: 10,\n  AND: 11,\n  NOT: 12,\n  \"=\": 20,\n  \"<>\": 20,\n  \">\": 20,\n  \">=\": 20,\n  \"<\": 20,\n  \"<=\": 20,\n  LIKE: 20,\n  \"NOT LIKE\": 20,\n  \"IS NULL\": 20,\n  \"IS NOT NULL\": 20,\n  \"||\": 30,\n  \"+\": 31,\n  \"-\": 31,\n  \"*\": 32,\n  \"/\": 32,\n  \"%\": 32,\n};\n\nfunction* range(s: number, e: number): Generator<number> {\n  for (let i = s; i < e; i++) yield i;\n}\n\nconst PATH_CHARS = new Set<number>([\n  ...range(65, 91),\n  ...range(97, 123),\n  ...range(48, 58),\n  95,\n  45,\n  42,\n  123,\n  125,\n]);\n\nfunction findOperator(cursor: Cursor): string {\n  cursor.skipwhitespace();\n  let found = \"\";\n  let foundCursor = cursor;\n  let operators = [...BINARY_OPERATORS, \"IS NULL\", \"IS NOT NULL\"];\n  for (let i = 0; operators.length && cursor.charCode; ++i) {\n    let c = cursor.charCode;\n    cursor.step();\n    if (c >= 97 && c <= 122) c -= 32;\n    if (c <= 32) {\n      cursor.skipwhitespace();\n      c = 32;\n    }\n    operators = operators.filter((o) => {\n      if (o.charCodeAt(i) !== c) return false;\n      if (o.length === i + 1) {\n        found = o;\n        foundCursor = cursor.fork();\n      }\n      return true;\n    });\n  }\n\n  if (found) cursor.sync(foundCursor);\n  return found;\n}\n\n// Turn escaped characters into real ones (e.g. \"\\\\n\" becomes \"\\n\").\nfunction interpretEscapes(str): string {\n  const escapes = {\n    b: \"\\b\",\n    f: \"\\f\",\n    n: \"\\n\",\n    r: \"\\r\",\n    t: \"\\t\",\n  };\n  return str.replace(/\\\\(u[0-9a-fA-F]{4}|[^u])/g, (_, escape) => {\n    const type = escape.charAt(0);\n    const hex = escape.slice(1);\n    if (type === \"u\") return String.fromCharCode(parseInt(hex, 16));\n\n    if (escapes.hasOwnProperty(type)) return escapes[type];\n\n    return type;\n  });\n}\n\nexport function parseExpression(cursor: Cursor, presedence = 0): Expression {\n  cursor.skipwhitespace();\n  const char = cursor.charCode;\n  let lhs: Expression;\n  if (char === CHAR_OPEN_PAREN) {\n    cursor.step();\n    cursor.descend([CHAR_CLOSE_PAREN]);\n    lhs = parseExpression(cursor, 0);\n    cursor.ascend();\n\n    cursor.skipwhitespace();\n    if (cursor.charCode !== CHAR_CLOSE_PAREN)\n      throw new Error(\"Expected ')'\" + cursor.pos + \" \" + cursor.charCode);\n    cursor.step();\n  } else if (char === CHAR_DOUBLE_QUOTE) {\n    cursor.step();\n    const cursor2 = cursor.fork();\n    for (\n      cursor2.descend([]);\n      cursor2.charCode !== CHAR_DOUBLE_QUOTE;\n      cursor2.step()\n    ) {\n      if (!cursor2.charCode) throw new Error(\"Unterminated string\");\n      if (cursor2.charCode === CHAR_BACKSLASH) cursor2.step();\n    }\n    cursor2.ascend();\n    const str = cursor.read(cursor2);\n    cursor.sync(cursor2);\n    cursor.step();\n    return new Expression.Literal(interpretEscapes(str));\n  } else if (char === CHAR_SINGLE_QUOTE) {\n    cursor.step();\n    const cursor2 = cursor.fork();\n    for (\n      cursor2.descend([]);\n      cursor2.charCode !== CHAR_SINGLE_QUOTE;\n      cursor2.step()\n    ) {\n      if (!cursor2.charCode) throw new Error(\"Unterminated string\");\n      if (cursor2.charCode === CHAR_BACKSLASH) cursor2.step();\n    }\n    cursor2.ascend();\n    const str = cursor.read(cursor2);\n    cursor.sync(cursor2);\n    cursor.step();\n    return new Expression.Literal(str.replaceAll(\"''\", \"'\"));\n  } else {\n    const cursor2 = cursor.fork();\n    cursor.walk(\n      (c) => c > 32 && c !== CHAR_OPEN_PAREN && c !== CHAR_OPEN_BRACKET,\n    );\n    const token = cursor2.read(cursor);\n    if (!token) throw new Error(\"Invalid expression\");\n    if (/^true$/i.test(token)) lhs = new Expression.Literal(true);\n    else if (/^false$/i.test(token)) lhs = new Expression.Literal(false);\n    else if (/^null$/i.test(token)) lhs = new Expression.Literal(null);\n    else if (/^-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?$/.test(token))\n      lhs = new Expression.Literal(Number(token));\n    else if (/^not$/i.test(token)) {\n      lhs = new Expression.Unary(\n        \"NOT\",\n        parseExpression(cursor, PRECEDENCE[\"NOT\"]),\n      );\n    } else if (/^case$/i.test(token)) {\n      const pairs: [Expression, Expression][] = [];\n      for (;;) {\n        cursor.skipwhitespace();\n        const whenStr = cursor.fork().read(cursor.walk((c) => c > 32));\n        if (/^else$/i.test(whenStr)) {\n          lhs = parseExpression(cursor);\n          continue;\n        }\n        if (/^end$/i.test(whenStr)) break;\n        else if (lhs) throw new Error(\"Expected END\");\n        if (!/^when$/i.test(whenStr)) throw new Error(\"Expected WHEN\");\n        const condition = parseExpression(cursor);\n        cursor.skipwhitespace();\n        const thenStr = cursor.fork().read(cursor.walk((c) => c > 32));\n        if (!/^then$/i.test(thenStr)) throw new Error(\"Expected THEN\");\n        const then = parseExpression(cursor);\n        pairs.push([condition, then]);\n      }\n      if (!lhs) lhs = new Expression.Literal(null);\n      while (pairs.length) {\n        const [condition, then] = pairs.pop();\n        lhs = new Expression.Conditional(condition, then, lhs);\n      }\n    } else if (cursor.charCode === CHAR_OPEN_PAREN) {\n      cursor.step();\n      cursor.descend([CHAR_CLOSE_PAREN]);\n      cursor.skipwhitespace();\n      const args = [] as Expression[];\n      while (cursor.charCode) {\n        cursor.descend([CHAR_COMMA], false);\n        const e = parseExpression(cursor);\n        args.push(e);\n        cursor.ascend();\n        cursor.skipwhitespace();\n        if ((cursor.charCode as number) !== CHAR_COMMA) break;\n        cursor.step();\n      }\n      cursor.ascend();\n      cursor.step();\n      lhs = new Expression.FunctionCall(token, args);\n    } else {\n      const p = parsePath(cursor2);\n      lhs = new Expression.Parameter(p);\n      cursor.sync(cursor2);\n    }\n  }\n\n  for (;;) {\n    cursor.skipwhitespace();\n    const cursor2 = cursor.fork();\n    const op = findOperator(cursor2);\n    const p = PRECEDENCE[op];\n    if (p <= presedence) return lhs;\n    if (op === \"IS NULL\") {\n      lhs = new Expression.Unary(\"IS NULL\", lhs);\n    } else if (op === \"IS NOT NULL\") {\n      lhs = new Expression.Unary(\"IS NOT NULL\", lhs);\n    } else if (BINARY_OPERATORS.includes(op)) {\n      lhs = new Expression.Binary(op, lhs, parseExpression(cursor2, p));\n    } else if (op) throw new Error(\"Unrecognized operator: \" + op);\n    else break;\n    cursor.sync(cursor2);\n  }\n  return lhs;\n}\n\n// Parse old-format alias value: unquoted, double-quoted, or single-quoted.\n// Returns the parsed string value. Cursor is advanced past the value.\nfunction parseOldAliasValue(cursor: Cursor): string {\n  cursor.walk((c) => c <= 32); // skip leading whitespace\n  const c = cursor.charCode;\n  if (c === CHAR_DOUBLE_QUOTE) {\n    // Double-quoted: use JSON.parse semantics (handles \\\", \\n, \\uXXXX etc.)\n    // Descend with no boundaries so commas/brackets inside quotes are not\n    // treated as terminators.\n    const start = cursor.pos;\n    cursor.step();\n    cursor.descend([]);\n    while (cursor.charCode) {\n      if (cursor.charCode === CHAR_BACKSLASH) {\n        cursor.step();\n        if (!cursor.charCode) break;\n      } else if (cursor.charCode === CHAR_DOUBLE_QUOTE) {\n        cursor.ascend();\n        cursor.step();\n        return JSON.parse(cursor.input.slice(start, cursor.pos)) as string;\n      }\n      cursor.step();\n    }\n    throw new Error(\"Unterminated string\");\n  }\n  if (c === CHAR_SINGLE_QUOTE) {\n    // Single-quoted: strip quotes, no escape processing\n    cursor.step();\n    cursor.descend([]);\n    const start = cursor.pos;\n    while (cursor.charCode && cursor.charCode !== CHAR_SINGLE_QUOTE)\n      cursor.step();\n    if (!cursor.charCode) throw new Error(\"Unterminated string\");\n    const value = cursor.input.slice(start, cursor.pos);\n    cursor.ascend();\n    cursor.step();\n    return value;\n  }\n  // Unquoted: read until , or ] (respecting boundary), trim result\n  const start = cursor.pos;\n  cursor.walk(() => true);\n  return cursor.input.slice(start, cursor.pos).trim();\n}\n\n// Parse old-format alias content: key:value,key:value,...\n// Cursor should be positioned at the start of bracket content\n// (after '[', with ']' set as boundary).\n// Returns an Expression: Binary(\"AND\", ...) chain of Binary(\"=\", param, literal).\n// Empty content returns Literal(true).\nfunction parseOldAlias(cursor: Cursor): Expression {\n  cursor.skipwhitespace();\n  if (!cursor.charCode) return new Expression.Literal(true);\n\n  let result: Expression | null = null;\n  for (;;) {\n    cursor.skipwhitespace();\n    // Parse key as a path (supports nested aliases via parsePath).\n    // Add colon and comma as boundaries so the path stops there.\n    cursor.descend([CHAR_COLON, CHAR_COMMA], false);\n    const keyPath = parsePath(cursor);\n    cursor.ascend();\n\n    // After ascend, check for the colon separator\n    cursor.skipwhitespace();\n    if ((cursor.charCode as number) !== CHAR_COLON)\n      throw new Error(\"Expected ':'\");\n    cursor.step();\n\n    // Parse value (reads until boundary , or ])\n    cursor.descend([CHAR_COMMA], false);\n    const value = parseOldAliasValue(cursor);\n    cursor.ascend();\n\n    const pair = new Expression.Binary(\n      \"=\",\n      new Expression.Parameter(keyPath),\n      new Expression.Literal(value),\n    );\n    result = result ? new Expression.Binary(\"AND\", result, pair) : pair;\n\n    cursor.skipwhitespace();\n    if ((cursor.charCode as number) !== CHAR_COMMA) break;\n    cursor.step();\n  }\n  return result;\n}\n\nexport function parsePath(cur: Cursor): Path {\n  const segments: (string | Expression)[] = [];\n  let colon = 0;\n  const cur2 = cur.fork();\n  for (;;) {\n    let char = cur2.charCode;\n    let exp: Expression;\n\n    if (char === CHAR_OPEN_BRACKET) {\n      if (cur2.pos !== cur.pos) throw new Error(\"Invalid path\");\n      cur2.step();\n      cur2.descend([CHAR_CLOSE_BRACKET]);\n      // Try parsing as expression first\n      const cur3 = cur2.fork();\n      let err: unknown;\n      try {\n        exp = parseExpression(cur3);\n      } catch (e) {\n        err = e;\n      }\n      // Fall back to old alias format if not a valid expression or produced\n      // a bare Parameter (old format like [a:1] mis-parsed as a path).\n      if (err || exp instanceof Expression.Parameter) {\n        exp = parseOldAlias(cur2);\n      } else {\n        cur2.sync(cur3);\n      }\n      cur2.ascend();\n      char = cur2.charCode;\n      if (char !== CHAR_CLOSE_BRACKET) throw new Error(\"Expected ']'\");\n      char = cur2.step().charCode;\n    }\n\n    if (!PATH_CHARS.has(char)) {\n      if (cur2.pos <= cur.pos) throw new Error(\"Invalid path\");\n\n      if (char === CHAR_COLON) {\n        if (colon) throw new Error(\"Multiple colons\");\n        colon = segments.length + 1;\n      }\n\n      if (exp) segments.push(exp);\n      else segments.push(cur.read(cur2));\n\n      if (char !== CHAR_PERIOD && char !== CHAR_COLON) break;\n      cur2.step();\n      cur.sync(cur2);\n      continue;\n    }\n\n    if (exp) throw new Error(\"Invalid path\");\n    cur2.step();\n  }\n  cur.sync(cur2);\n\n  if (colon) colon = segments.length - colon;\n  return new Path(segments, colon);\n}\n\nexport function stringifyExpression(exp: Expression, level = 0): string {\n  function wrap(e: string, op: string): string {\n    if (PRECEDENCE[op] <= level) return `(${e})`;\n    else return e;\n  }\n\n  if (exp instanceof Expression.Literal) {\n    if (exp.value == null) return \"NULL\";\n    if (exp.value === true) return \"TRUE\";\n    if (exp.value === false) return \"FALSE\";\n    return JSON.stringify(exp.value);\n  } else if (exp instanceof Expression.Unary) {\n    if (exp.operator === \"NOT\") {\n      return wrap(\n        `NOT ${stringifyExpression(exp.operand, PRECEDENCE[exp.operator])}`,\n        \"NOT\",\n      );\n    } else if (exp.operator === \"IS NULL\" || exp.operator === \"IS NOT NULL\") {\n      return wrap(\n        `${stringifyExpression(exp.operand, PRECEDENCE[exp.operator])} ${exp.operator}`,\n        exp.operator,\n      );\n    }\n  } else if (exp instanceof Expression.Binary) {\n    const op = exp.operator;\n    if (!(op in PRECEDENCE)) throw new Error(\"Invalid operator\");\n    return wrap(\n      `${stringifyExpression(exp.left, PRECEDENCE[op] - 1)} ${exp.operator} ${stringifyExpression(exp.right, PRECEDENCE[op])}`,\n      op,\n    );\n  } else if (exp instanceof Expression.Parameter) {\n    return exp.path.toString();\n  } else if (exp instanceof Expression.FunctionCall) {\n    return `${exp.name}(${exp.args.map((a) => stringifyExpression(a)).join(\", \")})`;\n  } else if (exp instanceof Expression.Conditional) {\n    let str = `CASE WHEN ${stringifyExpression(exp.condition)} THEN ${stringifyExpression(exp.then)}`;\n    if (\n      exp.otherwise instanceof Expression.Literal &&\n      exp.otherwise.value == null\n    )\n      str += \" END\";\n    else if (exp.otherwise instanceof Expression.Conditional)\n      str += stringifyExpression(exp.otherwise).slice(4);\n    else str += ` ELSE ${stringifyExpression(exp.otherwise)} END`;\n    return str;\n  }\n\n  throw new Error(\"Invalid expression\");\n}\n\nexport function parseLikePattern(pat: string, esc: string): string[] {\n  const chars = pat.split(\"\");\n\n  for (let i = 0; i < chars.length; ++i) {\n    const c = chars[i];\n    if (c === esc) {\n      chars[i] = chars[i + 1] || \"\";\n      chars[i + 1] = \"\";\n    } else if (c === \"_\") {\n      chars[i] = \"\\\\_\";\n    } else if (c === \"%\") {\n      chars[i] = \"\\\\%\";\n      while (chars[i + 1] === \"%\") chars[++i] = \"\";\n    }\n  }\n  return chars.filter((c) => c);\n}\n\nexport function likePatternToRegExp(pat: string, esc = \"\", flags = \"\"): RegExp {\n  const convChars = {\n    \"-\": \"\\\\-\",\n    \"/\": \"\\\\/\",\n    \"\\\\\": \"\\\\/\",\n    \"^\": \"\\\\^\",\n    $: \"\\\\$\",\n    \"*\": \"\\\\*\",\n    \"+\": \"\\\\+\",\n    \"?\": \"\\\\?\",\n    \".\": \"\\\\.\",\n    \"(\": \"\\\\(\",\n    \")\": \"\\\\)\",\n    \"|\": \"\\\\|\",\n    \"[\": \"\\\\[\",\n    \"]\": \"\\\\]\",\n    \"{\": \"\\\\{\",\n    \"}\": \"\\\\}\",\n    \"\\\\%\": \".*\",\n    \"\\\\_\": \".\",\n  };\n  let chars = parseLikePattern(pat, esc);\n  if (!chars.length) return new RegExp(\"^$\", flags);\n  chars = chars.map((c) => convChars[c] || c);\n  chars[0] = chars[0] === \".*\" ? \"\" : \"^\" + chars[0];\n  const l = chars.length - 1;\n  chars[l] = [\".*\", \"\"].includes(chars[l]) ? \"\" : chars[l] + \"$\";\n  return new RegExp(chars.join(\"\"), flags);\n}\n"
  },
  {
    "path": "lib/common/expression/synth.ts",
    "content": "import { espresso, complement, tautology } from \"espresso-iisojs\";\nimport Expression from \"../expression.ts\";\nimport { parseLikePattern } from \"./parser.ts\";\nimport normalize from \"./normalize.ts\";\n\ntype Minterm = number[];\n\nexport abstract class SynthContextBase<\n  T extends { toString: () => string } = unknown,\n  U = unknown,\n> {\n  public variables = new Map<string, number>();\n  protected clauses = new Map<number, U>();\n\n  public getVar(c: U): number {\n    const str = c.toString();\n    let idx = this.variables.get(str);\n    if (idx == null) {\n      idx = this.variables.size;\n      this.variables.set(str, idx);\n      this.clauses.set(idx, c);\n    }\n    return idx;\n  }\n\n  public getClause(v: number): U {\n    return this.clauses.get(v);\n  }\n\n  abstract getMinterms(exp: T, res: number): Minterm[];\n  abstract getDcSet(minterms: Minterm[]): Minterm[];\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  canRaise(idx: number, set: Set<number>): boolean {\n    return true;\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  canLower(idx: number, set: Set<number>): boolean {\n    return true;\n  }\n\n  bias(a: number, b: number): number {\n    // Bias towards 1 then lower index\n    return (b ^ 1) - (a ^ 1);\n  }\n\n  sanitizeMinterms(minterms: Minterm[]): Minterm[] {\n    return minterms;\n  }\n\n  minimize(minterms: Minterm[], dcSet: Minterm[] = []): Minterm[] {\n    minterms = this.sanitizeMinterms(minterms);\n    const canRaise = this.canRaise.bind(this);\n    const canLower = this.canLower.bind(this);\n    const bias = this.bias.bind(this);\n\n    return espresso(\n      minterms,\n      [...this.getDcSet([...minterms, ...dcSet]), ...dcSet],\n      {\n        canRaise,\n        canLower,\n        bias,\n      },\n    );\n  }\n}\n\nfunction* findIsNullDeps(exp: Expression): IterableIterator<Expression> {\n  if (exp instanceof Expression.Literal) return;\n  else if (exp instanceof Expression.Unary) {\n    if (exp.operator === \"IS NULL\" || exp.operator === \"IS NOT NULL\") return;\n    yield* findIsNullDeps(exp.operand);\n  } else if (exp instanceof Expression.Binary) {\n    yield* findIsNullDeps(exp.left);\n    yield* findIsNullDeps(exp.right);\n  } else if (exp instanceof Expression.FunctionCall) {\n    if (exp.name === \"NOW\") return;\n    else if (exp.name === \"LOWER\" || exp.name === \"UPPER\")\n      yield* findIsNullDeps(exp.args[0]);\n    else if (exp.name === \"ROUND\") {\n      for (const e of exp.args.slice(0, 2)) yield* findIsNullDeps(e);\n    }\n  } else yield exp;\n}\n\nexport abstract class Clause {\n  private _isNullable: Set<string>;\n  protected _expression: Expression;\n  abstract getMinterms(\n    context: SynthContextBase<Clause>,\n    res: number,\n  ): Minterm[];\n  expression(): Expression {\n    if (this._expression !== undefined) return this._expression;\n    const context = createSynthContext();\n    const minterms = this.getMinterms(context, 0b100);\n    const minimized = context.minimize(minterms);\n    this._expression = context.toExpression(minimized) as Expression;\n    return this._expression;\n  }\n  isBoolean(): boolean {\n    return true;\n  }\n  isNullable(c: Clause.IsNull): boolean {\n    if (!this._isNullable) {\n      this._isNullable = new Set(\n        [...this.getNullables()].map((n) => n.toString()),\n      );\n    }\n    return this._isNullable.has(c.toString());\n  }\n  *getNullables(): IterableIterator<Clause.IsNull> {\n    const exp = this.expression();\n    for (const e of findIsNullDeps(exp)) {\n      if (e === exp) yield new Clause.IsNull(this);\n      else yield new Clause.IsNull(new Clause.Exp(e));\n    }\n  }\n  toString(): string {\n    return this.expression().toString();\n  }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-namespace\nexport namespace Clause {\n  export class Not extends Clause {\n    constructor(public operand: Clause) {\n      super();\n    }\n    getMinterms(context: SynthContextBase<Clause>, res: number): Minterm[] {\n      let r = res & 0b001;\n      if (res & 0b010) r |= 0b100;\n      if (res & 0b100) r |= 0b010;\n      return this.operand.getMinterms(context, r);\n    }\n  }\n\n  export class And extends Clause {\n    constructor(public operands: Clause[]) {\n      super();\n    }\n    getMinterms(context: SynthContextBase<Clause>, res: number): Minterm[] {\n      res = res & 0b111;\n      if (!res) return [];\n      if (res === 0b111) return [[]];\n\n      if (res === 0b110)\n        return [\n          ...this.getMinterms(context, 0b010),\n          ...this.getMinterms(context, 0b100),\n        ];\n\n      if (!(res & 0b010)) return complement(this.getMinterms(context, ~res));\n\n      const minterms: Minterm[] = [];\n      for (const o of this.operands) {\n        const m = o.getMinterms(context, res);\n        if (m.length === 1 && !m[0].length) return [[]];\n        minterms.push(...m);\n      }\n\n      return minterms;\n    }\n  }\n\n  export class IsNull extends Clause {\n    constructor(public operand: Clause) {\n      super();\n    }\n    getMinterms(context: SynthContextBase<Clause>, res: number): Minterm[] {\n      res = res & 0b111;\n      const minterms: Minterm[] = [];\n      if ((res & 0b110) === 0b110) return [[]];\n      if (res & 0b100)\n        minterms.push(...this.operand.getMinterms(context, 0b001));\n      if (res & 0b010)\n        minterms.push(...this.operand.getMinterms(context, 0b110));\n      return minterms;\n    }\n    isBoolean(): boolean {\n      return true;\n    }\n    *getNullables(): IterableIterator<IsNull> {\n      // Never returns null\n    }\n    expression(): Expression {\n      const nullables = [...this.operand.getNullables()];\n      if (nullables.length === 1 && nullables[0].operand === this.operand)\n        return new Expression.Unary(\"IS NULL\", this.operand.expression());\n      return super.expression();\n    }\n  }\n\n  export class Exp extends Clause {\n    constructor(public exp: Expression) {\n      super();\n    }\n    getMinterms(context: SynthContextBase<Clause>, res: number): Minterm[] {\n      if (!(this.exp instanceof Expression.Literal))\n        return context.getMinterms(this, res);\n      if (this.exp.value == null) return res & 0b001 ? [[]] : [];\n      if (this.exp.value && res & 0b100) return [[]];\n      if (!this.exp.value && res & 0b010) return [[]];\n      return [];\n    }\n    expression(): Expression {\n      return this.exp;\n    }\n    isBoolean(): boolean {\n      if (this.exp instanceof Expression.Literal) {\n        return (\n          this.exp.value === true ||\n          this.exp.value === false ||\n          this.exp.value == null\n        );\n      }\n      return false;\n    }\n  }\n\n  export class Compare extends Clause {\n    constructor(\n      public lhs: Clause,\n      public op: \">\" | \"<\" | \"=\",\n      public rhs: boolean | number | string,\n    ) {\n      super();\n    }\n    getMinterms(context: SynthContextBase<Clause>, res: number): Minterm[] {\n      res = res & 0b111;\n      if (!res) return [];\n      if (res === 0b111) return [[]];\n      if (res === 0b001 || res === 0b110)\n        return this.lhs.getMinterms(context, res);\n      return context.getMinterms(this, res);\n    }\n    *getNullables(): IterableIterator<IsNull> {\n      yield* this.lhs.getNullables();\n    }\n    isBoolean(): boolean {\n      return true;\n    }\n    expression(): Expression {\n      return new Expression.Binary(\n        this.op,\n        this.lhs.expression(),\n        new Expression.Literal(this.rhs),\n      );\n    }\n  }\n\n  export class Like extends Clause {\n    public readonly caseSensitive: boolean;\n    public readonly contradiction: boolean;\n    public readonly pattern: string[];\n    public readonly lhs: Clause;\n\n    constructor(\n      lhs: Clause,\n      public rhs: string,\n      public esc?: string,\n    ) {\n      super();\n      const exp = lhs.expression();\n      let caseSensitive = true;\n      let contradiction = false;\n      if (exp instanceof Expression.FunctionCall) {\n        if (exp.name === \"UPPER\" || exp.name === \"LOWER\") {\n          const p =\n            exp.name === \"UPPER\" ? rhs.toUpperCase() : rhs.toLowerCase();\n          if (p === rhs) caseSensitive = false;\n          else contradiction = true;\n          lhs = new Exp(exp.args[0]);\n        }\n      }\n      this.lhs = lhs;\n      this.pattern = parseLikePattern(rhs, esc);\n      this.caseSensitive = caseSensitive;\n      this.contradiction = contradiction;\n    }\n    getMinterms(context: SynthContextBase<Clause>, res: number): Minterm[] {\n      res = res & 0b111;\n      if (!res) return [];\n      if (res === 0b111) return [[]];\n      if (res === 0b001 || res === 0b110)\n        return this.lhs.getMinterms(context, res);\n      return context.getMinterms(this, res);\n    }\n    isBoolean(): boolean {\n      return true;\n    }\n    isNullable(c: IsNull): boolean {\n      return this.lhs.isNullable(c);\n    }\n    getNullables(): IterableIterator<IsNull> {\n      return this.lhs.getNullables();\n    }\n    expression(): Expression {\n      let lhs = this.lhs.expression();\n      if (this.contradiction) {\n        if (this.rhs === this.rhs.toLocaleUpperCase())\n          lhs = new Expression.FunctionCall(\"LOWER\", [lhs]);\n        else lhs = new Expression.FunctionCall(\"UPPER\", [lhs]);\n      } else if (!this.caseSensitive) {\n        if (this.rhs === this.rhs.toLocaleUpperCase())\n          lhs = new Expression.FunctionCall(\"UPPER\", [lhs]);\n        else lhs = new Expression.FunctionCall(\"LOWER\", [lhs]);\n      }\n      return new Expression.Binary(\n        \"LIKE\",\n        lhs,\n        new Expression.Literal(this.rhs),\n      );\n    }\n  }\n\n  export class Conditional extends Clause {\n    constructor(\n      public condition: Clause,\n      public then: Clause,\n      public otherwise: Clause,\n    ) {\n      super();\n    }\n    getMinterms(context: SynthContextBase<Clause>, res: number): Minterm[] {\n      const condition = this.condition.getMinterms(context, 0b011);\n      if (!condition.length) return this.then.getMinterms(context, res);\n      return [\n        ...complement([...condition, ...this.then.getMinterms(context, ~res)]),\n        ...complement([\n          ...complement(condition),\n          ...this.otherwise.getMinterms(context, ~res),\n        ]),\n      ];\n    }\n    expression(): Expression {\n      if (this._expression != null) return this._expression;\n      if (this.isBoolean()) {\n        this._expression = new Clause.Not(new Clause.Not(this)).expression();\n        return this._expression;\n      }\n      const context = createSynthContext();\n      const cases: { when: Minterm[]; then: Expression }[] = [];\n      let clause = this as Conditional;\n\n      for (;;) {\n        let minterms = clause.condition.getMinterms(context, 0b100);\n        const then = clause.then.expression();\n        if (\n          cases.length &&\n          then.toString() === cases[cases.length - 1].then.toString()\n        )\n          minterms.push(...cases.pop().when);\n        minterms = context.minimize(\n          minterms,\n          cases.flatMap((c) => c.when),\n        );\n        if (!minterms.length) continue;\n        cases.push({ when: minterms, then });\n        if (minterms.length === 1 && !minterms[0].length) break;\n        if (clause.otherwise instanceof Conditional) clause = clause.otherwise;\n        else\n          clause = new Conditional(\n            new Exp(new Expression.Literal(true)),\n            clause.otherwise,\n            new Exp(new Expression.Literal(null)),\n          );\n      }\n      while (\n        cases[cases.length - 1].then instanceof Expression.Literal &&\n        (cases[cases.length - 1].then as Expression.Literal).value == null\n      )\n        cases.pop();\n      let res: Expression = new Expression.Literal(null);\n      while (cases.length) {\n        const c = cases.pop();\n        if (c.when.length === 1 && !c.when[0].length) {\n          res = c.then;\n        } else {\n          res = new Expression.Conditional(\n            context.toExpression(c.when),\n            c.then,\n            res,\n          );\n        }\n      }\n\n      this._expression = res;\n      return this._expression;\n    }\n\n    isBoolean(): boolean {\n      return this.then.isBoolean() && this.otherwise.isBoolean();\n    }\n  }\n\n  export function fromExpression(exp: Expression): Clause {\n    let res: Clause;\n    let negate = false;\n    if (exp instanceof Expression.Unary) {\n      let op = exp.operator;\n      negate = true;\n      if (op === \"IS NOT NULL\") op = \"IS NULL\";\n      else negate = false;\n      if (op === \"NOT\") res = new Clause.Not(fromExpression(exp.operand));\n      else if (op === \"IS NULL\") res = new IsNull(fromExpression(exp.operand));\n    } else if (exp instanceof Expression.Binary) {\n      let op = exp.operator;\n      negate = true;\n      if (op === \"NOT LIKE\") op = \"LIKE\";\n      else if (op === \"<>\") op = \"=\";\n      else if (op === \">=\") op = \"<\";\n      else if (op === \"<=\") op = \">\";\n      else negate = false;\n\n      if (op === \"AND\") {\n        res = new Clause.And([\n          fromExpression(exp.left),\n          fromExpression(exp.right),\n        ]);\n      } else if (op === \"OR\") {\n        negate = true;\n        res = new Clause.And([\n          new Clause.Not(fromExpression(exp.left)),\n          new Clause.Not(fromExpression(exp.right)),\n        ]);\n      } else if (exp.right instanceof Expression.Literal) {\n        if ([\"=\", \">\", \"<\"].includes(op)) {\n          if (\n            [\"boolean\", \"number\", \"string\"].includes(typeof exp.right.value)\n          ) {\n            res = new Compare(\n              fromExpression(exp.left),\n              op as \">\" | \"<\" | \"=\",\n              exp.right.value,\n            );\n          }\n        } else if (op === \"LIKE\") {\n          if (typeof exp.right.value === \"string\")\n            res = new Like(fromExpression(exp.left), exp.right.value);\n        }\n      }\n    } else if (exp instanceof Expression.Conditional) {\n      res = new Conditional(\n        fromExpression(exp.condition),\n        fromExpression(exp.then),\n        fromExpression(exp.otherwise),\n      );\n    }\n\n    if (!res) res = new Exp(exp);\n    if (negate) res = new Not(res);\n    return res;\n  }\n}\n\nfunction groupBy<T, K>(\n  input: T[],\n  callback: (item: T) => K,\n): Iterable<[K, T[]]> {\n  const groups = new Map<K, T[]>();\n  for (const item of input) {\n    const key = callback(item);\n    let arr = groups.get(key);\n    if (!arr) groups.set(key, (arr = []));\n    arr.push(item);\n  }\n\n  return groups.entries();\n}\n\nexport class SynthContext extends SynthContextBase<Clause, Clause> {\n  constructor() {\n    super();\n  }\n\n  getMinterms(clause: Clause, res: number): number[][] {\n    const v = this.getVar(clause);\n    switch (res & 0b111) {\n      case 0b100:\n        return [[(v << 2) ^ 3]];\n      case 0b010:\n        return [[(v << 2) ^ 1]];\n      case 0b001:\n        return [[v << 2, (v << 2) ^ 2]];\n      case 0b110:\n        return [[(v << 2) ^ 3], [(v << 2) ^ 1]];\n      case 0b101:\n        return [[(v << 2) ^ 3], [v << 2, (v << 2) ^ 2]];\n      case 0b011:\n        return [[(v << 2) ^ 1], [v << 2, (v << 2) ^ 2]];\n      default:\n        throw new Error(\"Invalid minterms\");\n    }\n  }\n\n  getDcSet(minterms: Minterm[]): number[][] {\n    const dcSet: number[][] = [];\n\n    const whitelist = new Set([...minterms.flat()].map((v) => v >> 2));\n\n    const allClauses = [...whitelist].map((v) => this.getClause(v));\n    const comparisons = allClauses.filter(\n      (c) => c instanceof Clause.Compare,\n    ) as Clause.Compare[];\n\n    // Comparisons\n    for (const [, clauses] of groupBy(comparisons, (c) => c.lhs.toString())) {\n      const lhs = clauses[0].lhs;\n      const values = new Set(clauses.map((c) => c.rhs));\n      const valuesSorted = [...values].sort((a, b) => {\n        const ta = typeof a;\n        const tb = typeof b;\n        if (ta === tb) return a > b ? 1 : -1;\n        else if (ta === \"string\") return 1;\n        else if (tb === \"string\") return -1;\n        return +a - +b;\n      });\n\n      for (const [i, v] of valuesSorted.entries()) {\n        const eq = this.getVar(new Clause.Compare(lhs, \"=\", v));\n        const gt = this.getVar(new Clause.Compare(lhs, \">\", v));\n        const lt = this.getVar(new Clause.Compare(lhs, \"<\", v));\n\n        dcSet.push([(eq << 2) ^ 3, (gt << 2) ^ 3]);\n        dcSet.push([(lt << 2) ^ 3, (gt << 2) ^ 3]);\n        dcSet.push([(lt << 2) ^ 3, (eq << 2) ^ 3]);\n        dcSet.push([(lt << 2) ^ 1, (eq << 2) ^ 1, (gt << 2) ^ 1]);\n\n        const negEquivOp = [lt, eq, gt].filter((o) => !whitelist.has(o));\n        if (negEquivOp.length === 1) whitelist.add(negEquivOp[0]);\n\n        for (let j = 0; j < i; j++) {\n          const eq2 = this.getVar(\n            new Clause.Compare(lhs, \"=\", valuesSorted[j]),\n          );\n          const gt2 = this.getVar(\n            new Clause.Compare(lhs, \">\", valuesSorted[j]),\n          );\n          const lt2 = this.getVar(\n            new Clause.Compare(lhs, \"<\", valuesSorted[j]),\n          );\n\n          // This is the minimum clauses required if all relavent vars\n          // were included in the DC set.\n          // dcSet.push([(eq << 2) ^ 3, (eq2 << 2) ^ 3]);\n          // dcSet.push([(lt << 2) ^ 1, (gt2 << 2) ^ 1]);\n\n          // But we use non-minimal set because intermediate vars\n          // between any two may not be present in the DC set.\n          dcSet.push([(gt2 << 2) ^ 1, (lt << 2) ^ 1]);\n          dcSet.push([(eq2 << 2) ^ 3, (lt << 2) ^ 1]);\n          dcSet.push([(lt2 << 2) ^ 3, (lt << 2) ^ 1]);\n          dcSet.push([(gt2 << 2) ^ 1, (gt << 2) ^ 3]);\n          dcSet.push([(gt2 << 2) ^ 1, (eq << 2) ^ 3]);\n          dcSet.push([(eq2 << 2) ^ 3, (gt << 2) ^ 3]);\n          dcSet.push([(eq2 << 2) ^ 3, (eq << 2) ^ 3]);\n          dcSet.push([(lt2 << 2) ^ 3, (gt << 2) ^ 3]);\n          dcSet.push([(lt2 << 2) ^ 3, (eq << 2) ^ 3]);\n        }\n      }\n    }\n\n    // LIKE\n    const likes = allClauses.filter(\n      (c) => c instanceof Clause.Like,\n    ) as Clause.Like[];\n\n    for (const [, clauses] of groupBy(likes, (c) => c.lhs.toString())) {\n      for (let i1 = 0; i1 < clauses.length; ++i1) {\n        const l1 = clauses[i1];\n        if (l1.contradiction) {\n          dcSet.push([(this.getVar(l1) << 2) ^ 3]);\n          continue;\n        }\n        for (let i2 = i1 + 1; i2 < clauses.length; ++i2) {\n          const l2 = clauses[i2];\n          if (l2.contradiction) continue;\n          let p1 = l1.pattern;\n          let p2 = l2.pattern;\n          if (!l1.caseSensitive || !l2.caseSensitive) {\n            p1 = p1.map((c) => c.toLowerCase());\n            p2 = p2.map((c) => c.toLowerCase());\n          }\n          if (likeDisjoint(p1, p2)) {\n            dcSet.push([\n              (this.getVar(l1) << 2) ^ 3,\n              (this.getVar(l2) << 2) ^ 3,\n            ]);\n          } else if (\n            (!l1.caseSensitive || l2.caseSensitive) &&\n            likeImplies(p1, p2)\n          ) {\n            dcSet.push([\n              (this.getVar(l1) << 2) ^ 2,\n              (this.getVar(l2) << 2) ^ 3,\n            ]);\n            dcSet.push([\n              (this.getVar(l1) << 2) ^ 1,\n              (this.getVar(l2) << 2) ^ 0,\n            ]);\n          } else if (\n            (!l2.caseSensitive || l1.caseSensitive) &&\n            likeImplies(p2, p1)\n          ) {\n            dcSet.push([\n              (this.getVar(l1) << 2) ^ 3,\n              (this.getVar(l2) << 2) ^ 2,\n            ]);\n            dcSet.push([\n              (this.getVar(l1) << 2) ^ 0,\n              (this.getVar(l2) << 2) ^ 1,\n            ]);\n          }\n        }\n      }\n    }\n\n    for (const [lhsKey, likeGroup] of groupBy(likes, (c) => c.lhs.toString())) {\n      const compareGroupAll = comparisons.filter(\n        (c) => c.lhs.toString() === lhsKey,\n      );\n\n      for (const like of likeGroup) {\n        if (like.contradiction) continue;\n\n        const pattern = like.caseSensitive\n          ? like.pattern\n          : like.pattern.map((c) => c.toLowerCase());\n\n        const likeVar = this.getVar(like);\n\n        for (const compare of compareGroupAll.filter((c) => c.op === \"=\")) {\n          if (typeof compare.rhs !== \"string\") continue;\n\n          const value = like.caseSensitive\n            ? compare.rhs\n            : compare.rhs.toLowerCase();\n\n          const matches = likeMatches(pattern, value, true);\n          const eqVar = this.getVar(compare);\n\n          if (matches) {\n            dcSet.push([(eqVar << 2) ^ 3, (likeVar << 2) ^ 1]);\n            // Don't add eq=true AND like=null as DC; combined with eq=true AND\n            // like=false, espresso would treat LIKE as don't-care when eq=true\n          } else {\n            dcSet.push([(eqVar << 2) ^ 3, (likeVar << 2) ^ 3]);\n          }\n        }\n\n        // Prefix patterns like 'abc%' match strings in range [prefix, upperBound)\n        const prefix = getPureLikePrefix(pattern);\n        if (prefix) {\n          const upperBound = getLikePrefixUpperBound(prefix);\n\n          // string < prefix means it can't match the pattern\n          for (const compare of compareGroupAll.filter((c) => c.op === \"<\")) {\n            if (typeof compare.rhs !== \"string\") continue;\n\n            const value = like.caseSensitive\n              ? compare.rhs\n              : compare.rhs.toLowerCase();\n            const ltVar = this.getVar(compare);\n\n            if (value <= prefix) {\n              dcSet.push([(ltVar << 2) ^ 3, (likeVar << 2) ^ 3]);\n            }\n          }\n\n          // string > upperBound means it can't match the pattern\n          // (string > prefix could still match, e.g., 'abcd' > 'abc' matches 'abc%')\n          if (upperBound) {\n            for (const compare of compareGroupAll.filter((c) => c.op === \">\")) {\n              if (typeof compare.rhs !== \"string\") continue;\n\n              const value = like.caseSensitive\n                ? compare.rhs\n                : compare.rhs.toLowerCase();\n              const gtVar = this.getVar(compare);\n\n              if (value >= upperBound) {\n                dcSet.push([(gtVar << 2) ^ 3, (likeVar << 2) ^ 3]);\n              }\n            }\n          }\n        }\n      }\n    }\n\n    for (const v of whitelist) {\n      const clause = this.getClause(v);\n      const nullables = [...clause.getNullables()].map((c) => this.getVar(c));\n      if (nullables.length) {\n        dcSet.push([\n          ...nullables.map((n) => (n << 2) ^ 2),\n          (v << 2) ^ 0,\n          (v << 2) ^ 2,\n        ]);\n        for (const n of nullables) {\n          dcSet.push([(n << 2) ^ 3, (v << 2) ^ 1]);\n          dcSet.push([(n << 2) ^ 3, (v << 2) ^ 3]);\n          whitelist.add(n);\n        }\n      }\n\n      if (clause instanceof Clause.IsNull) {\n        if (clause.operand instanceof Clause.Exp) {\n          if (clause.operand.exp instanceof Expression.Parameter) {\n            const str = clause.operand.exp.path.toString();\n            if (str === \"DeviceID.ID\" || str === \"_id\")\n              dcSet.push([(v << 2) ^ 3]);\n          }\n        }\n      }\n    }\n    return dcSet.filter((m) => m.every((v) => whitelist.has(v >> 2)));\n  }\n\n  canRaise(idx: number, set: Set<number>): boolean {\n    const clause = this.getClause(idx >> 2);\n    if (clause instanceof Clause.IsNull) {\n      for (const i of set) {\n        if (i === idx || i & 1) continue;\n        const c = this.getClause(i >> 2);\n        if (c.isNullable(clause)) return false;\n      }\n      return true;\n    }\n    return !(idx & 1) || !set.has(idx ^ 3);\n  }\n\n  canLower(idx: number, set: Set<number>): boolean {\n    if (idx & 1) return true;\n    const clause = this.getClause(idx >> 2);\n    if (clause instanceof Clause.IsNull) return true;\n    return set.has(idx ^ 3) || set.has(idx ^ 1);\n  }\n\n  bias(a: number, b: number): number {\n    // Bias towards 1 then true\n    return ((b & 3) ^ 3) - ((a & 3) ^ 3);\n  }\n\n  sanitizeMinterms(minterms: Minterm[]): Minterm[] {\n    const res = [] as number[][];\n\n    loop: for (const m of minterms) {\n      const merged: Map<number, number> = new Map();\n      for (const i of m)\n        merged.set(i >> 2, (merged.get(i >> 2) || 0) | (1 << (i & 3)));\n      const minterm: number[] = [];\n      const perms: number[][] = [];\n      for (const [k, v] of merged) {\n        if ((v & 0b0011) === 0b0011) continue loop;\n        if ((v & 0b1100) === 0b1100) continue loop;\n        const clause = this.clauses.get(k);\n        if (!clause) throw new Error(\"Invalid literal\");\n        if (clause instanceof Clause.IsNull) {\n          if (v === 0b0100) minterm.push((k << 2) ^ 2);\n          else if (v === 0b1000) minterm.push((k << 2) ^ 3);\n          else throw new Error(\"Invalid literal\");\n          continue;\n        }\n        if ((v & 0b1010) === 0b1010) continue loop;\n        const isNullVars = [...clause.getNullables()].map((c) =>\n          this.getVar(c),\n        );\n        const t = k << 2;\n        if (v === 0b0101) {\n          if (isNullVars.length === 1) minterm.push((isNullVars[0] << 2) ^ 3);\n          else perms.push(isNullVars.map((n) => (n << 2) ^ 3));\n        } else if (v === 0b0001) {\n          perms.push([...isNullVars.map((n) => (n << 2) ^ 3), t ^ 3]);\n        } else if (v === 0b0100) {\n          perms.push([...isNullVars.map((n) => (n << 2) ^ 3), t ^ 1]);\n        } else if (v & 0b1000) {\n          minterm.push(t ^ 3);\n        } else if (v & 0b0010) {\n          minterm.push(t ^ 1);\n        }\n      }\n      let ms = [minterm];\n      while (perms.length) {\n        const newMs: number[][] = [];\n        const perm = perms.pop();\n        for (const p of perm) newMs.push(...ms.map((mm) => [...mm, p]));\n        ms = newMs;\n      }\n      res.push(...ms);\n    }\n    return res;\n  }\n\n  toExpression(sop: number[][]): Expression {\n    let res: Expression = new Expression.Literal(false);\n    for (const s of sop) {\n      let conjs: Expression = new Expression.Literal(true);\n      for (const i of s) {\n        const clause = this.getClause(i >>> 2);\n        if (!clause) throw new Error(\"Invalid literal\");\n        if (clause instanceof Clause.IsNull) {\n          if (!(i & 2)) throw new Error(\"Invalid literal\");\n        } else if (!(i & 1)) {\n          // Should never be reached if minimized correctly\n          const isNullVars = [...clause.getNullables()].map((c) =>\n            this.getVar(c),\n          );\n          conjs = Expression.and(\n            conjs,\n            this.toExpression([\n              [i ^ 3],\n              ...isNullVars.map((n) => [(n << 2) ^ 3]),\n            ]),\n          );\n          continue;\n        }\n\n        let expr = clause.expression();\n        if (!(i & 1) !== !(i & 2)) expr = new Expression.Unary(\"NOT\", expr);\n        if (expr instanceof Expression.Unary && expr.operator === \"NOT\") {\n          const e = expr.operand;\n          if (e instanceof Expression.Unary) {\n            if (e.operator === \"IS NULL\")\n              expr = new Expression.Unary(\"IS NOT NULL\", e.operand);\n            else if (e.operator === \"IS NOT NULL\")\n              expr = new Expression.Unary(\"IS NULL\", e.operand);\n            else if (e.operator === \"NOT\") expr = e.operand;\n          } else if (e instanceof Expression.Binary) {\n            if (e.operator === \"LIKE\")\n              expr = new Expression.Binary(\"NOT LIKE\", e.left, e.right);\n            else if (e.operator === \"=\")\n              expr = new Expression.Binary(\"<>\", e.left, e.right);\n            else if (e.operator === \"<>\")\n              expr = new Expression.Binary(\"=\", e.left, e.right);\n            else if (e.operator === \">\")\n              expr = new Expression.Binary(\"<=\", e.left, e.right);\n            else if (e.operator === \">=\")\n              expr = new Expression.Binary(\"<\", e.left, e.right);\n            else if (e.operator === \"<\")\n              expr = new Expression.Binary(\">=\", e.left, e.right);\n            else if (e.operator === \"<=\")\n              expr = new Expression.Binary(\">\", e.left, e.right);\n          }\n        }\n        conjs = Expression.and(conjs, expr);\n      }\n      res = Expression.or(res, conjs);\n    }\n    return res;\n  }\n}\n\n// Classes aren't hoisted in JS but functions are. This function is used to\n// create a new SynthContext instance from inside the Clause class.\nexport function createSynthContext(): SynthContext {\n  return new SynthContext();\n}\n\nfunction likeMatches(\n  pattern: string[],\n  value: string,\n  caseSensitive: boolean,\n): boolean {\n  if (!caseSensitive) {\n    value = value.toLowerCase();\n    pattern = pattern.map((c) =>\n      c === \"\\\\%\" || c === \"\\\\_\" ? c : c.toLowerCase(),\n    );\n  }\n\n  let pi = 0;\n  let vi = 0;\n  let backtrackPi = -1;\n  let backtrackVi = -1;\n\n  while (vi < value.length) {\n    if (pi < pattern.length && pattern[pi] === \"\\\\%\") {\n      backtrackPi = pi;\n      backtrackVi = vi;\n      pi++;\n    } else if (\n      pi < pattern.length &&\n      (pattern[pi] === \"\\\\_\" || pattern[pi] === value[vi])\n    ) {\n      pi++;\n      vi++;\n    } else if (backtrackPi >= 0) {\n      pi = backtrackPi + 1;\n      backtrackVi++;\n      vi = backtrackVi;\n    } else {\n      return false;\n    }\n  }\n\n  while (pi < pattern.length && pattern[pi] === \"\\\\%\") pi++;\n\n  return pi === pattern.length;\n}\n\nfunction getLikePrefixUpperBound(prefix: string): string | null {\n  if (!prefix) return null;\n\n  for (let i = prefix.length - 1; i >= 0; i--) {\n    const charCode = prefix.charCodeAt(i);\n    // 0x10ffff is max Unicode code point; sufficient for practical strings\n    if (charCode < 0x10ffff) {\n      return prefix.slice(0, i) + String.fromCodePoint(charCode + 1);\n    }\n  }\n  return null;\n}\n\nfunction getPureLikePrefix(pattern: string[]): string | null {\n  if (pattern.length === 0) return null;\n\n  let hasTrailingPercent = false;\n  let prefix = \"\";\n\n  for (let i = 0; i < pattern.length; i++) {\n    const c = pattern[i];\n    if (c === \"\\\\%\") {\n      for (let j = i; j < pattern.length; j++) {\n        if (pattern[j] !== \"\\\\%\") return null;\n      }\n      hasTrailingPercent = true;\n      break;\n    } else if (c === \"\\\\_\") {\n      return null;\n    } else {\n      prefix += c;\n    }\n  }\n\n  if (!hasTrailingPercent) return null;\n  if (!prefix) return null;\n\n  return prefix;\n}\n\nexport function likeImplies(pat1: string[], pat2: string[]): boolean {\n  let backtrack: [number, number] = null;\n\n  for (let i1 = 0, i2 = 0; ; ++i1, ++i2) {\n    while (i1 < pat1.length && pat1[i1] === \"\\\\%\") backtrack = [i1++, i2];\n\n    if (i2 >= pat2.length) return i1 >= pat1.length;\n\n    const c = i1 < pat1.length ? pat1[i1] : null;\n\n    if (c !== pat2[i2] && c !== \"\\\\_\") {\n      if (!backtrack) return false;\n      [i1, i2] = backtrack;\n      ++backtrack[1];\n    }\n  }\n}\n\nexport function likeDisjoint(pat1: string[], pat2: string[]): boolean {\n  const left1Idx = pat1.indexOf(\"\\\\%\");\n  const left2Idx = pat2.indexOf(\"\\\\%\");\n  const right1Idx = pat1.lastIndexOf(\"\\\\%\");\n  const right2Idx = pat2.lastIndexOf(\"\\\\%\");\n\n  const left1 = pat1.slice(0, left1Idx !== -1 ? left1Idx : pat1.length);\n  const left2 = pat2.slice(0, left2Idx !== -1 ? left2Idx : pat2.length);\n  const right1 = pat1.slice(right1Idx !== -1 ? right1Idx + 1 : 0).reverse();\n  const right2 = pat2.slice(right2Idx !== -1 ? right2Idx + 1 : 0).reverse();\n\n  for (let i = 0; i < Math.min(left1.length, left2.length); ++i) {\n    if (left1[i] !== left2[i] && left1[i] !== \"\\\\_\" && left2[i] !== \"\\\\_\")\n      return true;\n  }\n\n  for (let i = 0; i < Math.min(right1.length, right2.length); ++i) {\n    if (right1[i] !== right2[i] && right1[i] !== \"\\\\_\" && right2[i] !== \"\\\\_\")\n      return true;\n  }\n\n  if (pat1.length === left1.length)\n    return pat2.filter((c) => c !== \"\\\\%\").length > pat1.length;\n  else if (pat2.length === left2.length)\n    return pat1.filter((c) => c !== \"\\\\%\").length > pat2.length;\n\n  return false;\n}\n\nexport function minimize(expr: Expression, boolean = false): Expression {\n  let synth = Clause.fromExpression(normalize(expr));\n  if (boolean) synth = new Clause.Not(new Clause.Not(synth));\n  return synth.expression();\n}\n\nexport function unionDiff(\n  expr1: Expression,\n  expr2: Expression,\n): [Expression, Expression] {\n  expr2 = normalize(expr2);\n\n  if (expr2 instanceof Expression.Literal && !expr2.value)\n    return [expr1, new Expression.Literal(false)];\n\n  const synth2 = Clause.fromExpression(expr2);\n\n  if (expr1 instanceof Expression.Literal && !expr1.value) {\n    const e = synth2.expression();\n    return [e, e];\n  }\n\n  expr1 = normalize(expr1);\n  const synth1 = Clause.fromExpression(expr1);\n\n  const context = new SynthContext();\n\n  const expr2Minterms = synth2.getMinterms(context, 0b100);\n  const expr1Minterms = synth1.getMinterms(context, 0b100);\n\n  const union = context.minimize([...expr1Minterms, ...expr2Minterms]);\n\n  const diff = context.minimize(\n    complement([...expr1Minterms, ...complement(expr2Minterms)]),\n  );\n\n  return [context.toExpression(union), context.toExpression(diff)];\n}\n\nexport function covers(expr1: Expression, expr2: Expression): boolean {\n  expr2 = normalize(expr2);\n  if (expr2 instanceof Expression.Literal && !expr2.value) return true;\n  expr1 = normalize(expr1);\n  if (expr1 instanceof Expression.Literal && expr1.value) return true;\n\n  const synt1 = Clause.fromExpression(expr1);\n  const synt2 = Clause.fromExpression(expr2);\n\n  const context = new SynthContext();\n  const expr1Minterms = synt1.getMinterms(context, 0b100);\n  const expr2Minterms = synt2.getMinterms(context, 0b100);\n\n  return tautology([\n    ...context.sanitizeMinterms(complement(expr2Minterms)),\n    ...context.getDcSet([...expr2Minterms, ...expr1Minterms]),\n    ...context.sanitizeMinterms(expr1Minterms),\n  ]);\n}\n\nexport function areEquivalent(expr1: Expression, expr2: Expression): boolean {\n  expr1 = normalize(expr1);\n  expr2 = normalize(expr2);\n\n  // Both are trivial (true/false)\n  if (\n    expr1 instanceof Expression.Literal &&\n    expr2 instanceof Expression.Literal\n  ) {\n    return !!expr1.value === !!expr2.value;\n  }\n\n  const synth1 = Clause.fromExpression(expr1);\n  const synth2 = Clause.fromExpression(expr2);\n\n  const context = new SynthContext();\n  const expr1Minterms = synth1.getMinterms(context, 0b100);\n  const expr2Minterms = synth2.getMinterms(context, 0b100);\n  const dcSet = context.getDcSet([...expr1Minterms, ...expr2Minterms]);\n\n  // Equivalent iff expr1 covers expr2 AND expr2 covers expr1\n  // expr1 covers expr2: NOT(expr2) OR expr1 is tautology\n  // expr2 covers expr1: NOT(expr1) OR expr2 is tautology\n  const notExpr1 = complement(expr1Minterms);\n  const notExpr2 = complement(expr2Minterms);\n\n  return (\n    tautology([\n      ...context.sanitizeMinterms([...notExpr2, ...expr1Minterms]),\n      ...dcSet,\n    ]) &&\n    tautology([\n      ...context.sanitizeMinterms([...notExpr1, ...expr2Minterms]),\n      ...dcSet,\n    ])\n  );\n}\n\n// Returns expr2 - expr1 (what's in expr2 but not in expr1)\nexport function subtract(expr1: Expression, expr2: Expression): Expression {\n  expr2 = normalize(expr2);\n  if (expr2 instanceof Expression.Literal && !expr2.value)\n    return new Expression.Literal(false);\n\n  expr1 = normalize(expr1);\n  if (expr1 instanceof Expression.Literal && !expr1.value) {\n    const synth2 = Clause.fromExpression(expr2);\n    return synth2.expression();\n  }\n\n  if (expr1 instanceof Expression.Literal && expr1.value)\n    return new Expression.Literal(false);\n\n  const synth1 = Clause.fromExpression(expr1);\n  const synth2 = Clause.fromExpression(expr2);\n\n  const context = new SynthContext();\n  const expr1Minterms = synth1.getMinterms(context, 0b100);\n  const expr2Minterms = synth2.getMinterms(context, 0b100);\n\n  const diff = context.minimize(\n    complement([...expr1Minterms, ...complement(expr2Minterms)]),\n  );\n\n  return context.toExpression(diff);\n}\n"
  },
  {
    "path": "lib/common/expression.ts",
    "content": "import {\n  Cursor,\n  parseExpression,\n  stringifyExpression,\n} from \"./expression/parser.ts\";\nimport Path from \"./path.ts\";\nimport { reduce } from \"./expression/evaluate.ts\";\n\nexport type Value = string | number | boolean | null;\n\nexport abstract class Expression {\n  private _string: string;\n\n  abstract map(fn: (e: Expression, i: number) => Expression): Expression;\n  abstract mapAsync(\n    fn: (e: Expression, i: number) => Promise<Expression>,\n  ): Promise<Expression>;\n\n  toString(): string {\n    if (!this._string) this._string = stringifyExpression(this);\n    return this._string;\n  }\n\n  evaluate<T extends Expression>(fn: (e: Expression) => T = (e) => e as T): T {\n    return fn(reduce(this.map((e) => e.evaluate(fn))));\n  }\n\n  async evaluateAsync<T extends Expression>(\n    fn: (e: Expression) => Promise<T>,\n  ): Promise<T> {\n    return await fn(\n      reduce(await this.mapAsync(async (e) => await e.evaluateAsync(fn))),\n    );\n  }\n\n  static parse(input: string): Expression {\n    const cursor = new Cursor(input);\n    const exp = parseExpression(cursor);\n    if (cursor.charCode) throw new Error(\"Unexpected character\");\n    return exp;\n  }\n\n  static and(left: Expression, right: Expression): Expression {\n    // Flatten same-operator tree into operands\n    const operands: Expression[] = [];\n    const stack: Expression[] = [right, left];\n    while (stack.length) {\n      const e = stack.pop()!;\n      if (e instanceof Expression.Binary && e.operator === \"AND\")\n        stack.push(e.right, e.left);\n      else operands.push(e);\n    }\n\n    // Fold literals using three-valued AND logic\n    let folded: boolean | null = true;\n    let i = 0;\n    while (i < operands.length) {\n      const e = operands[i];\n      if (e instanceof Expression.Literal) {\n        operands.splice(i, 1);\n        if (e.value == null) folded = folded === false ? false : null;\n        else if (!e.value) return new Expression.Literal(false);\n        // truthy is identity for AND; discard\n      } else {\n        i++;\n      }\n    }\n\n    // Rebuild: folded value + remaining non-literal operands\n    if (!operands.length) return new Expression.Literal(folded);\n    let result = operands.reduce((a, b) => new Expression.Binary(\"AND\", a, b));\n    if (folded === null)\n      result = new Expression.Binary(\n        \"AND\",\n        new Expression.Literal(null),\n        result,\n      );\n    return result;\n  }\n\n  static or(left: Expression, right: Expression): Expression {\n    // Flatten same-operator tree into operands\n    const operands: Expression[] = [];\n    const stack: Expression[] = [right, left];\n    while (stack.length) {\n      const e = stack.pop()!;\n      if (e instanceof Expression.Binary && e.operator === \"OR\")\n        stack.push(e.right, e.left);\n      else operands.push(e);\n    }\n\n    // Fold literals using three-valued OR logic\n    let folded: boolean | null = false;\n    let i = 0;\n    while (i < operands.length) {\n      const e = operands[i];\n      if (e instanceof Expression.Literal) {\n        operands.splice(i, 1);\n        if (e.value == null) folded = folded === true ? true : null;\n        else if (e.value) return new Expression.Literal(true);\n        // falsy is identity for OR; discard\n      } else {\n        i++;\n      }\n    }\n\n    // Rebuild: folded value + remaining non-literal operands\n    if (!operands.length) return new Expression.Literal(folded);\n    let result = operands.reduce((a, b) => new Expression.Binary(\"OR\", a, b));\n    if (folded === null)\n      result = new Expression.Binary(\n        \"OR\",\n        new Expression.Literal(null),\n        result,\n      );\n    return result;\n  }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-namespace\nexport namespace Expression {\n  export class Literal extends Expression {\n    constructor(public readonly value: Value) {\n      super();\n    }\n\n    map(): Literal {\n      return this;\n    }\n\n    async mapAsync(): Promise<Literal> {\n      return this;\n    }\n  }\n\n  export class Parameter extends Expression {\n    constructor(public readonly path: Path) {\n      super();\n    }\n\n    map(): Parameter {\n      return this;\n    }\n\n    async mapAsync(): Promise<Parameter> {\n      return this;\n    }\n\n    toString(): string {\n      return this.path.toString();\n    }\n  }\n\n  export class Unary extends Expression {\n    constructor(\n      public readonly operator: string,\n      public readonly operand: Expression,\n    ) {\n      super();\n    }\n\n    map(fn: (e: Expression, i: number) => Expression): Unary {\n      const operand = fn(this.operand, 0);\n      if (operand === this.operand) return this;\n      return Reflect.construct(this.constructor, [this.operator, operand]);\n    }\n\n    async mapAsync(\n      fn: (e: Expression, i: number) => Promise<Expression>,\n    ): Promise<Unary> {\n      const operand = await fn(this.operand, 0);\n      if (operand === this.operand) return this;\n      return Reflect.construct(this.constructor, [this.operator, operand]);\n    }\n  }\n\n  export class Binary extends Expression {\n    constructor(\n      public readonly operator: string,\n      public readonly left: Expression,\n      public readonly right: Expression,\n    ) {\n      super();\n    }\n\n    map(fn: (e: Expression, i: number) => Expression): Binary {\n      const left = fn(this.left, 0);\n      const right = fn(this.right, 1);\n      if (left === this.left && right === this.right) return this;\n      return Reflect.construct(this.constructor, [this.operator, left, right]);\n    }\n\n    async mapAsync(\n      fn: (e: Expression, i: number) => Promise<Expression>,\n    ): Promise<Binary> {\n      const left = await fn(this.left, 0);\n      const right = await fn(this.right, 1);\n      if (left === this.left && right === this.right) return this;\n      return Reflect.construct(this.constructor, [this.operator, left, right]);\n    }\n  }\n\n  export class FunctionCall extends Expression {\n    constructor(\n      public readonly name: string,\n      public readonly args: Expression[],\n    ) {\n      super();\n    }\n\n    map(fn: (e: Expression, i: number) => Expression): FunctionCall {\n      const args = this.args.map(fn);\n      if (args.every((arg, i) => arg === this.args[i])) return this;\n      return new FunctionCall(this.name, args);\n    }\n\n    async mapAsync(\n      fn: (e: Expression, i: number) => Promise<Expression>,\n    ): Promise<FunctionCall> {\n      const args = await Promise.all(this.args.map(fn));\n      if (args.every((arg, i) => arg === this.args[i])) return this;\n      return new FunctionCall(this.name, args);\n    }\n  }\n\n  export class Conditional extends Expression {\n    constructor(\n      public readonly condition: Expression,\n      public readonly then: Expression,\n      public readonly otherwise: Expression,\n    ) {\n      super();\n    }\n\n    map(fn: (e: Expression, i: number) => Expression): Conditional {\n      const condition = fn(this.condition, 0);\n      const then = fn(this.then, 1);\n      const otherwise = fn(this.otherwise, 2);\n      if (\n        condition === this.condition &&\n        then === this.then &&\n        otherwise === this.otherwise\n      )\n        return this;\n      return new Conditional(condition, then, otherwise);\n    }\n\n    async mapAsync(\n      fn: (e: Expression, i: number) => Promise<Expression>,\n    ): Promise<Conditional> {\n      const condition = await fn(this.condition, 0);\n      const then = await fn(this.then, 1);\n      const otherwise = await fn(this.otherwise, 2);\n      if (\n        condition === this.condition &&\n        then === this.then &&\n        otherwise === this.otherwise\n      )\n        return this;\n      return new Conditional(condition, then, otherwise);\n    }\n  }\n}\n\nexport function extractPaths(exp: Expression): Path[] {\n  if (exp instanceof Expression.Parameter) return [exp.path];\n  const paths: Path[] = [];\n  exp.map((e) => {\n    if (e instanceof Expression.Parameter) paths.push(e.path);\n    else paths.push(...extractPaths(e));\n    return e;\n  });\n  return paths;\n}\n\nexport function parseList(input: string): Expression[] {\n  const CHAR_COMMA = 44;\n  const res: Expression[] = [];\n  const cursor = new Cursor(input);\n  cursor.skipwhitespace();\n  if (!cursor.charCode) return res;\n  res.push(parseExpression(cursor));\n  while (cursor.charCode === CHAR_COMMA) {\n    cursor.step();\n    res.push(parseExpression(cursor));\n  }\n  if (cursor.charCode) throw new Error(\"Unexpected character\");\n  return res;\n}\n\nexport default Expression;\n"
  },
  {
    "path": "lib/common/memoize.ts",
    "content": "let cache1 = new Map();\nlet cache2 = new Map();\nconst keys = new WeakMap();\n\nfunction getKey(obj): string {\n  if (obj === null) return \"null\";\n  else if (obj === undefined) return \"undefined\";\n\n  const t = typeof obj;\n  if (t === \"number\" || t === \"boolean\" || t === \"string\") return `${t}:${obj}`;\n  if (t !== \"function\" && t !== \"object\")\n    throw new Error(`Cannot memoize ${t} arguments`);\n\n  let k = keys.get(obj);\n  if (!k) {\n    const rnd = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER);\n    k = `${t}:${rnd.toString(36)}`;\n    keys.set(obj, k);\n  }\n  return k;\n}\n\nexport default function memoize<T extends (...args: any[]) => any>(func: T): T {\n  const funcKey = getKey(func);\n  return ((...args) => {\n    const key = JSON.stringify(args.map(getKey)) + funcKey;\n\n    if (cache1.has(key)) return cache1.get(key);\n\n    let r;\n    if (cache2.has(key)) {\n      cache1.set(key, (r = cache2.get(key)));\n    } else {\n      cache1.set(key, (r = func(...args)));\n      // Evict rejected promises\n      if (r instanceof Promise) {\n        r.catch(() => {\n          cache1.delete(key);\n          cache2.delete(key);\n        });\n      }\n    }\n    return r;\n  }) as any;\n}\n\nconst interval = setInterval(() => {\n  cache2 = cache1;\n  cache1 = new Map();\n}, 120000);\n\n// Don't hold Node.js process\nif (interval.unref) interval.unref();\n"
  },
  {
    "path": "lib/common/path-set.ts",
    "content": "import Path from \"./path.ts\";\n\nexport default class PathSet {\n  private paramSegmentIndex: Map<string, Set<Path>>[] = [];\n  private attrSegmentIndex: Map<string, Set<Path>>[] = [];\n  private stringIndex: Map<string, Path> = new Map();\n\n  public constructor() {}\n\n  public add(pathStr: string): Path {\n    let path: Path = this.get(pathStr);\n    if (path) return path;\n    path = Path.parse(pathStr);\n    if (path.alias) throw new Error(\"PathSet does not support aliased paths\");\n\n    this.stringIndex.set(path.toString(), path);\n\n    while (this.paramSegmentIndex.length < path.paramLength)\n      this.paramSegmentIndex.push(new Map());\n\n    while (this.attrSegmentIndex.length < path.attrLength)\n      this.attrSegmentIndex.push(new Map());\n\n    for (let i = 0; i < path.length; ++i) {\n      const fragment = path.segments[i] as string;\n      const fragmentIndex =\n        i < path.paramLength\n          ? this.paramSegmentIndex[i]\n          : this.attrSegmentIndex[i - path.paramLength];\n\n      let fragmentIndexSet = fragmentIndex.get(fragment);\n      if (!fragmentIndexSet) {\n        fragmentIndexSet = new Set<Path>();\n        fragmentIndex.set(fragment, fragmentIndexSet);\n      }\n\n      fragmentIndexSet.add(path);\n    }\n\n    return path;\n  }\n\n  public get(path: string): Path {\n    return this.stringIndex.get(path);\n  }\n\n  public findCompat(\n    path: Path,\n    superset = false,\n    subset = false,\n    depth = path.length,\n  ): Path[] {\n    if (path.attrLength)\n      throw new Error(\"findCompat() does not support attribute paths\");\n\n    depth = Math.min(31, depth);\n    const paramMask = (1 << path.paramLength) - 1;\n    let mask = ((0b10 << depth) - 1) & ~paramMask;\n    if (superset) mask |= ~path.wildcard & paramMask;\n    if (subset) mask |= path.wildcard;\n    return this.find(path, mask, 1);\n  }\n\n  public find(path: Path, paramMask: number, attrMask: number): Path[] {\n    if (path.alias) throw new Error(\"PathSet does not support aliased paths\");\n    if (path.paramLength > this.paramSegmentIndex.length) return [];\n    if (path.attrLength > this.attrSegmentIndex.length) return [];\n\n    const emptySet: Set<Path> = new Set();\n\n    const indexes: [Set<Path>, Set<Path>][] = [];\n\n    const paramLengthMask = (1 << path.paramLength) - 1;\n    const attrLengthMask = (1 << path.attrLength) - 1;\n\n    const mask = (paramMask & paramLengthMask) | (attrMask << path.paramLength);\n\n    for (const [i, s] of path.segments.entries()) {\n      const b = 1 << i;\n      const m = mask & b;\n      const w = b & path.wildcard;\n      if (w && m) continue;\n      const idxSet =\n        i < path.paramLength\n          ? this.paramSegmentIndex[i]\n          : this.attrSegmentIndex[i - path.paramLength];\n      const idx1 = idxSet.get(s as string) || emptySet;\n      let idx2 = emptySet;\n      if (m) idx2 = idxSet.get(\"*\") || emptySet;\n      indexes.push([idx1, idx2]);\n    }\n\n    indexes.sort((a, b) => a[0].size + a[1].size - (b[0].size + b[1].size));\n\n    let res: Path[];\n\n    if (!indexes.length) res = [...this.stringIndex.values()];\n    else res = [...indexes[0][0], ...indexes[0][1]];\n\n    const paramCover = ~paramLengthMask & paramMask;\n    const attrCover = ~attrLengthMask & attrMask;\n\n    res = res.filter(\n      (p) =>\n        (1 << p.paramLength) & paramCover && (1 << p.attrLength) & attrCover,\n    );\n\n    for (let i = 1; i < indexes.length; ++i) {\n      const [idx1, idx2] = indexes[i];\n      res = res.filter((p) => idx1.has(p) || idx2.has(p));\n    }\n\n    return res;\n  }\n}\n"
  },
  {
    "path": "lib/common/path.ts",
    "content": "import Expression from \"./expression.ts\";\nimport { Cursor, parsePath } from \"./expression/parser.ts\";\n\ntype Segments = (string | Expression)[];\n\nlet cache1 = new Map<string, Path>();\nlet cache2 = new Map<string, Path>();\n\nexport default class Path {\n  declare public readonly segments: Segments;\n  declare public readonly colon: number;\n  declare public readonly wildcard: number;\n  declare public readonly alias: number;\n  declare protected _string: string;\n  declare protected _stringIndex: number[];\n\n  constructor(segments: Segments, colon: number) {\n    if (!(colon <= segments.length)) throw new Error(\"Invalid path\");\n    if (segments.length > 32) throw new Error(\"Path too long\");\n\n    Object.freeze(segments);\n\n    let alias = 0;\n    let wildcard = 0;\n    const arr = segments.map((s, i) => {\n      if (s instanceof Expression) {\n        alias |= 1 << i;\n        return `[${s.toString()}]`;\n      } else if (s === \"*\") {\n        wildcard |= 1 << i;\n      }\n      return s;\n    });\n\n    let offset = 0;\n    const stringIndex = arr.map((s, i) => (offset += s.length) + i);\n\n    this.segments = segments;\n    this.colon = colon;\n    this.wildcard = wildcard;\n    this.alias = alias;\n    if (!colon) this._string = arr.join(\".\");\n    else\n      this._string =\n        arr.slice(0, -colon).join(\".\") + \":\" + arr.slice(-colon).join(\".\");\n    this._stringIndex = stringIndex;\n  }\n\n  public static parse(input: string): Path {\n    let path = cache1.get(input);\n    if (!path) {\n      path = cache2.get(input);\n      if (!path) {\n        const cursor = new Cursor(input);\n        path = parsePath(cursor);\n        if (cursor.charCode) throw new Error(\"Unexpected character\");\n        if (path.toString() !== input) cache1.set(path.toString(), path);\n      }\n      cache1.set(input, path);\n    }\n    return path;\n  }\n\n  public get length(): number {\n    return this.segments.length;\n  }\n\n  public get paramLength(): number {\n    return this.segments.length - this.colon;\n  }\n\n  public get attrLength(): number {\n    return this.colon;\n  }\n\n  public toString(): string {\n    return this._string;\n  }\n\n  public slice(start = 0, end: number = this.segments.length): Path {\n    if (start < 0) start = Math.max(0, this.segments.length + start);\n    if (end < 0) end = Math.max(0, this.segments.length + end);\n\n    if (start >= end) return Path.root;\n\n    let i1 = start > 0 ? this._stringIndex[start - 1] + 1 : 0;\n    // Include the \":\" when slicing exactly at the colon boundary\n    if (this.colon && start === this.segments.length - this.colon) --i1;\n    const i2 =\n      end <= this.segments.length\n        ? this._stringIndex[end - 1]\n        : this._string.length;\n    const str = this._string.slice(i1, i2);\n\n    let path = cache1.get(str);\n    if (!path) {\n      path = cache2.get(str);\n      if (!path) {\n        const segments = this.segments.slice(start, end);\n        const colon =\n          start <= this.segments.length - this.colon\n            ? Math.max(0, this.colon - this.segments.length + end)\n            : 0;\n        path = new Path(segments, colon);\n      }\n      cache1.set(str, path);\n    }\n\n    return path;\n  }\n\n  public concat(path2: Path): Path {\n    if (!path2._string) return this;\n    else if (!this._string) return path2;\n\n    if (this.colon && path2.colon && path2.colon < path2.segments.length)\n      throw new Error(\"Invalid path\");\n\n    const colon = this.colon ? this.colon + path2.segments.length : path2.colon;\n\n    let str;\n    if (this.colon && path2.colon === path2.segments.length) {\n      // Right is all-colon; strip its \":\" prefix and join with \".\"\n      str = `${this._string}.${path2._string.slice(1)}`;\n    } else if (path2.colon === path2.segments.length) {\n      // Left has no colon; right is all-colon; concatenate directly\n      str = `${this._string}${path2._string}`;\n    } else {\n      str = `${this._string}.${path2._string}`;\n    }\n\n    let path = cache1.get(str);\n    if (!path) {\n      path = cache2.get(str);\n      if (!path) {\n        const segments = this.segments.concat(path2.segments);\n        path = new Path(segments, colon);\n      }\n      cache1.set(str, path);\n    }\n\n    return path;\n  }\n\n  public stripAlias(): Path {\n    if (!this.alias) return this;\n    const segments = this.segments.map((s) =>\n      s instanceof Expression ? \"*\" : s,\n    );\n    let str: string;\n    if (!this.colon) str = segments.join(\".\");\n    else\n      str =\n        segments.slice(0, -this.colon).join(\".\") +\n        \":\" +\n        segments.slice(-this.colon).join(\".\");\n\n    let path = cache1.get(str);\n    if (!path) {\n      path = cache2.get(str);\n      if (!path) {\n        path = new Path(segments, this.colon);\n      }\n      cache1.set(str, path);\n    }\n\n    return path;\n  }\n\n  static root = new Path([], 0);\n}\n\nconst interval = setInterval(() => {\n  cache2 = cache1;\n  cache1 = new Map();\n}, 120000);\n\n// Don't hold Node.js process\nif (interval.unref) interval.unref();\n"
  },
  {
    "path": "lib/common/yaml.ts",
    "content": "const LINE_WIDTH = 80;\nconst INDENTATION = \"  \";\n\nconst STRING_RESERVED = new Set([\n  \"true\",\n  \"True\",\n  \"TRUE\",\n  \"false\",\n  \"False\",\n  \"FALSE\",\n  \"null\",\n  \"Null\",\n  \"NULL\",\n]);\n\nfunction isPrintable(str: string): boolean {\n  return !/[^\\t\\n\\x20-\\x7e\\x85\\u{a0}-\\u{d7ff}\\u{e000}-\\u{fffd}\\u{10000}-\\u{10ffff}]/u.test(\n    str,\n  );\n}\n\nfunction stringifyKey(str: string): string {\n  if (!str || !isPrintable(str)) return JSON.stringify(str);\n  if (/^[\\s-?:,[\\]{}#&$!|>'\"%@`]|: | #|[\\n,[\\]{}]|\\s$/.test(str))\n    return JSON.stringify(str);\n  return str;\n}\n\nfunction foldString(str: string): string[] {\n  if (str.length <= LINE_WIDTH) return [str];\n  if (str.startsWith(\" \")) return [str];\n  const lines: string[] = [];\n\n  let idx = 0;\n  let cand = 0;\n  for (let i = 1; i < str.length - 1; ++i) {\n    if (str[i] !== \" \") continue;\n\n    if (str[i + 1] === \" \") {\n      i += 2;\n      while (str[i] === \" \") ++i;\n      continue;\n    }\n\n    if (i <= idx + LINE_WIDTH) {\n      cand = i;\n      continue;\n    }\n\n    const c = cand > idx ? cand : i;\n    lines.push(str.slice(idx, c));\n    idx = c + 1;\n    cand = i;\n  }\n\n  if (cand > idx && str.length > idx + LINE_WIDTH) {\n    lines.push(str.slice(idx, cand));\n    idx = cand + 1;\n  }\n\n  lines.push(str.slice(idx));\n\n  return lines;\n}\n\nfunction stringifyString(str: string, res: string[], prefix1, prefix2): void {\n  if (/^\\s*$/.test(str) || STRING_RESERVED.has(str) || !isPrintable(str)) {\n    res.push(prefix1 + JSON.stringify(str));\n    return;\n  }\n\n  if (!prefix2) prefix2 = INDENTATION;\n\n  const lines = str.split(\"\\n\");\n  if (lines.length > 1) {\n    let idt = \"\";\n    let chmp = \"-\";\n    if ((lines.find((l) => l) || \"\").startsWith(\" \"))\n      idt = `${INDENTATION.length}`;\n\n    if (!lines[lines.length - 1]) {\n      lines.pop();\n      if (lines[lines.length - 1]) chmp = \"\";\n      else chmp = \"+\";\n    }\n\n    if (/^\\s+$/.test(lines[lines.length - 1])) {\n      res.push(prefix1 + JSON.stringify(str));\n      return;\n    }\n\n    let isFolded = false;\n    const folded = lines.map((l) => {\n      const ls = foldString(l);\n      if (ls.length > 1) isFolded = true;\n      return ls;\n    });\n\n    if (!isFolded) {\n      res.push(\n        `${prefix1}|${idt}${chmp}`,\n        ...lines.map((l) => (l ? prefix2 + l : l)),\n      );\n      return;\n    }\n\n    res.push(`${prefix1}>${idt}${chmp}`);\n    res.push(...folded[0].map((f) => prefix2 + f));\n    for (let i = 1; i < folded.length; ++i) {\n      const prevLine = folded[i - 1][0];\n      if (prevLine && !folded[i - 1][0].startsWith(\" \")) res.push(\"\");\n      res.push(...folded[i].map((f) => prefix2 + f));\n    }\n    return;\n  }\n\n  if (\n    /^[\\s-?:,[\\]{}#&$!|>'\"%@`]|: | #|\\s$/.test(str) ||\n    parseFloat(str) === +str\n  ) {\n    res.push(prefix1 + JSON.stringify(str));\n    return;\n  }\n\n  res.push(prefix1 + str);\n}\n\nfunction stringifyAny(\n  obj: unknown,\n  res: string[],\n  prefix1 = \"\",\n  prefix2 = \"\",\n): void {\n  if (obj == null) {\n    res.push(`${prefix1}null`);\n    return;\n  }\n  if (typeof obj === \"number\" || typeof obj === \"boolean\") {\n    res.push(`${prefix1}${JSON.stringify(obj)}`);\n    return;\n  }\n  if (obj instanceof Date) {\n    res.push(`${prefix1}${obj.toJSON()}`);\n    return;\n  }\n\n  if (typeof obj === \"string\") {\n    stringifyString(obj, res, prefix1, prefix2);\n    return;\n  }\n\n  if (Array.isArray(obj)) {\n    if (!obj.length) {\n      res.push(prefix1 + \"[]\");\n      return;\n    }\n\n    if (!prefix1 || prefix1.endsWith(\"- \")) {\n      stringifyAny(obj[0], res, prefix1 + \"- \", prefix2 + INDENTATION);\n      prefix1 = prefix2 + \"- \";\n      prefix2 = prefix2 + INDENTATION;\n      for (let i = 1; i < obj.length; ++i)\n        stringifyAny(obj[i], res, prefix1, prefix2);\n    } else {\n      res.push(prefix1);\n      prefix1 = prefix2 + \"- \";\n      prefix2 = prefix2 + INDENTATION;\n      for (let i = 0; i < obj.length; ++i)\n        stringifyAny(obj[i], res, prefix1, prefix2);\n    }\n    return;\n  }\n\n  const entries = Object.entries(obj).filter((e) => e[1] !== undefined);\n\n  if (!entries.length) {\n    res.push(prefix1 + \"{}\");\n    return;\n  }\n\n  if (!prefix1 || prefix1.endsWith(\"- \")) {\n    stringifyAny(\n      entries[0][1],\n      res,\n      prefix1 + `${stringifyKey(entries[0][0])}: `,\n      prefix2 + INDENTATION,\n    );\n    prefix1 = prefix2;\n    prefix2 = prefix2 + INDENTATION;\n    for (let i = 1; i < entries.length; ++i) {\n      stringifyAny(\n        entries[i][1],\n        res,\n        prefix1 + `${stringifyKey(entries[i][0])}: `,\n        prefix2,\n      );\n    }\n  } else {\n    res.push(prefix1);\n    prefix1 = prefix2;\n    prefix2 = prefix2 + INDENTATION;\n    for (let i = 0; i < entries.length; ++i) {\n      stringifyAny(\n        entries[i][1],\n        res,\n        prefix1 + `${stringifyKey(entries[i][0])}: `,\n        prefix2,\n      );\n    }\n  }\n}\n\nexport function stringify(obj: unknown): string {\n  if (obj === undefined) return undefined;\n  const lines: string[] = [];\n  stringifyAny(obj, lines);\n  return lines.join(\"\\n\") + \"\\n\";\n}\n"
  },
  {
    "path": "lib/config.ts",
    "content": "import { resolve } from \"node:path\";\nimport { readFileSync, existsSync } from \"node:fs\";\n\n// Find project root directory\nexport let ROOT_DIR = resolve(__dirname, \"..\");\nwhile (!existsSync(`${ROOT_DIR}/package.json`)) {\n  const d = resolve(ROOT_DIR, \"..\");\n  if (d === ROOT_DIR) {\n    ROOT_DIR = process.cwd();\n    break;\n  }\n  ROOT_DIR = d;\n}\n\n// For compatibility with v1.1\nlet configDir, cwmpSsl, nbiSsl, fsSsl, uiSsl, fsHostname;\n\nconst options = {\n  EXT_DIR: { type: \"path\", default: resolve(ROOT_DIR, \"config/ext\") },\n  MONGODB_CONNECTION_URL: {\n    type: \"string\",\n    default: \"mongodb://127.0.0.1/genieacs\",\n  },\n\n  CWMP_WORKER_PROCESSES: { type: \"int\", default: 0 },\n  CWMP_PORT: { type: \"int\", default: 7547 },\n  CWMP_INTERFACE: { type: \"string\", default: \"::\" },\n  CWMP_SSL_CERT: { type: \"string\", default: \"\" },\n  CWMP_SSL_KEY: { type: \"string\", default: \"\" },\n  CWMP_LOG_FILE: { type: \"path\", default: \"\" },\n  CWMP_ACCESS_LOG_FILE: { type: \"path\", default: \"\" },\n\n  NBI_WORKER_PROCESSES: { type: \"int\", default: 0 },\n  NBI_PORT: { type: \"int\", default: 7557 },\n  NBI_INTERFACE: { type: \"string\", default: \"::\" },\n  NBI_SSL_CERT: { type: \"string\", default: \"\" },\n  NBI_SSL_KEY: { type: \"string\", default: \"\" },\n  NBI_LOG_FILE: { type: \"path\", default: \"\" },\n  NBI_ACCESS_LOG_FILE: { type: \"path\", default: \"\" },\n\n  FS_WORKER_PROCESSES: { type: \"int\", default: 0 },\n  FS_PORT: { type: \"int\", default: 7567 },\n  FS_INTERFACE: { type: \"string\", default: \"::\" },\n  FS_SSL_CERT: { type: \"string\", default: \"\" },\n  FS_SSL_KEY: { type: \"string\", default: \"\" },\n  FS_URL_PREFIX: { type: \"string\", default: \"\" },\n  FS_LOG_FILE: { type: \"path\", default: \"\" },\n  FS_ACCESS_LOG_FILE: { type: \"path\", default: \"\" },\n\n  UI_WORKER_PROCESSES: { type: \"int\", default: 0 },\n  UI_PORT: { type: \"int\", default: 3000 },\n  UI_INTERFACE: { type: \"string\", default: \"::\" },\n  UI_SSL_CERT: { type: \"string\", default: \"\" },\n  UI_SSL_KEY: { type: \"string\", default: \"\" },\n  UI_LOG_FILE: { type: \"path\", default: \"\" },\n  UI_ACCESS_LOG_FILE: { type: \"path\", default: \"\" },\n  UI_JWT_SECRET: { type: \"string\", default: \"\" },\n\n  UDP_CONNECTION_REQUEST_PORT: { type: \"int\", default: 0 },\n  FORWARDED_HEADER: { type: \"string\", default: \"\" },\n\n  DOWNLOAD_TIMEOUT: { type: \"int\", default: 3600 },\n  EXT_TIMEOUT: { type: \"int\", default: 3000 },\n  MAX_CACHE_TTL: { type: \"int\", default: 86400 },\n  DEBUG_FILE: { type: \"path\", default: \"\" },\n  DEBUG_FORMAT: { type: \"string\", default: \"yaml\" },\n  DEBUG: { type: \"bool\", default: false },\n  RETRY_DELAY: { type: \"int\", default: 300 },\n  SESSION_TIMEOUT: { type: \"int\", default: 30 },\n  CONNECTION_REQUEST_TIMEOUT: { type: \"int\", default: 2000 },\n  GPN_NEXT_LEVEL: { type: \"int\", default: 0 },\n  GPV_BATCH_SIZE: { type: \"int\", default: 32 },\n  MAX_DEPTH: { type: \"int\", default: 16 },\n  COOKIES_PATH: { type: \"string\" },\n  LOG_FORMAT: { type: \"string\", default: \"simple\" },\n  ACCESS_LOG_FORMAT: { type: \"string\", default: \"\" },\n  MAX_CONCURRENT_REQUESTS: { type: \"int\", default: 20 },\n  DATETIME_MILLISECONDS: { type: \"bool\", default: true },\n  BOOLEAN_LITERAL: { type: \"bool\", default: true },\n  CONNECTION_REQUEST_ALLOW_BASIC_AUTH: { type: \"bool\", default: false },\n  MAX_COMMIT_ITERATIONS: { type: \"int\", default: 32 },\n\n  // Should probably never be changed\n  DEVICE_ONLINE_THRESHOLD: { type: \"int\", default: 4000 },\n\n  XMPP_JID: { type: \"string\", default: \"\" },\n  XMPP_PASSWORD: { type: \"string\", default: \"\" },\n};\n\nconst allConfig: { [name: string]: string | number } = {};\n\nfunction setConfig(name, value, commandLineArgument = false): boolean {\n  if (allConfig[name] != null) return true;\n\n  // For compatibility with v1.1\n  if (name === \"CONFIG_DIR\" || name === \"config-dir\")\n    configDir = configDir || resolve(ROOT_DIR, value);\n\n  if (name === \"CWMP_SSL\" || name === \"cwmp-ssl\")\n    cwmpSsl = cwmpSsl || String(value).toLowerCase().trim();\n\n  if (name === \"NBI_SSL\" || name === \"nbi-ssl\")\n    nbiSsl = nbiSsl || String(value).toLowerCase().trim();\n\n  if (name === \"FS_SSL\" || name === \"fs-ssl\")\n    fsSsl = fsSsl || String(value).toLowerCase().trim();\n\n  if (name === \"UI_SSL\" || name === \"ui-ssl\")\n    uiSsl = uiSsl || String(value).toLowerCase().trim();\n\n  if (name === \"FS_HOSTNAME\" || name === \"fs-hostname\")\n    fsHostname = fsHostname || String(value).trim();\n\n  // For compatibility with v1.0\n  if (name === \"PRESETS_CACHE_DURATION\" || name === \"presets-cache-duration\")\n    setConfig(\"MAX_CACHE_TTL\", value);\n\n  if (\n    name === \"GET_PARAMETER_NAMES_DEPTH_THRESHOLD\" ||\n    name === \"get-parameter-names-depth-threshold\"\n  )\n    setConfig(\"GPN_NEXT_LEVEL\", value);\n\n  if (\n    name === \"TASK_PARAMETERS_BATCH_SIZE\" ||\n    name === \"task-parameters-batch-size\"\n  )\n    setConfig(\"GPV_BATCH_SIZE\", value);\n\n  if (name === \"FS_IP\" || name === \"fs-ip\") setConfig(\"FS_HOSTNAME\", value);\n\n  function cast(val, type): string | number | boolean {\n    switch (type) {\n      case \"int\":\n        return Number(val);\n      case \"bool\":\n        return [\"true\", \"1\"].includes(String(val).trim().toLowerCase());\n      case \"string\":\n        return String(val);\n      case \"path\":\n        if (!val) return \"\";\n        return resolve(val);\n      default:\n        return null;\n    }\n  }\n\n  let _value = null;\n  for (const [optionName, optionDetails] of Object.entries(options)) {\n    let n = optionName;\n    if (commandLineArgument) n = n.toLowerCase().replace(/_/g, \"-\");\n\n    if (name === n) {\n      _value = cast(value, optionDetails.type);\n      n = optionName;\n    } else if (name.startsWith(`${n}-`)) {\n      _value = cast(value, optionDetails.type);\n      n = `${optionName}-${name.slice(optionName.length + 1)}`;\n    }\n\n    if (_value != null) {\n      allConfig[n] = _value;\n      // Save as environmnet variable to pass on to any child process\n      process.env[`GENIEACS_${n}`] = _value;\n      return true;\n    }\n  }\n\n  return false;\n}\n\n// Command line arguments\nconst argv = process.argv.slice(2);\nwhile (argv.length) {\n  const arg = argv.shift();\n  if (arg[0] === \"-\") {\n    const v = argv.shift();\n    setConfig(arg.slice(2), v, true);\n  }\n}\n\n// Environment variable\nfor (const [k, v] of Object.entries(process.env))\n  if (k.startsWith(\"GENIEACS_\")) setConfig(k.slice(9), v);\n\n// Configuration file\nconst configFilename = configDir\n  ? `${configDir}/config.json`\n  : `${ROOT_DIR}/config/config.json`;\n\nif (existsSync(configFilename)) {\n  const configFile = JSON.parse(readFileSync(configFilename).toString());\n\n  for (const [k, v] of Object.entries(configFile)) {\n    if (!setConfig(k, v))\n      // Pass as environment variable to be accessable by extensions\n      process.env[`GENIEACS_${k}`] = `${v}`;\n  }\n}\n\nif (configDir) setConfig(\"EXT_DIR\", `${configDir}/ext`);\n\nif ([\"true\", \"1\"].includes(cwmpSsl)) {\n  const d = configDir || `${ROOT_DIR}/config`;\n  setConfig(\"CWMP_SSL_CERT\", `${d}/cwmp.crt`);\n  setConfig(\"CWMP_SSL_KEY\", `${d}/cwmp.key`);\n}\n\nif ([\"true\", \"1\"].includes(nbiSsl)) {\n  const d = configDir || `${ROOT_DIR}/config`;\n  setConfig(\"NBI_SSL_CERT\", `${d}/cwmp.crt`);\n  setConfig(\"NBI_SSL_KEY\", `${d}/cwmp.key`);\n}\n\nif ([\"true\", \"1\"].includes(fsSsl)) {\n  const d = configDir || `${ROOT_DIR}/config`;\n  setConfig(\"FS_SSL_CERT\", `${d}/cwmp.crt`);\n  setConfig(\"FS_SSL_KEY\", `${d}/cwmp.key`);\n}\n\nif ([\"true\", \"1\"].includes(uiSsl)) {\n  const d = configDir || `${ROOT_DIR}/config`;\n  setConfig(\"UI_SSL_CERT\", `${d}/cwmp.crt`);\n  setConfig(\"UI_SSL_KEY\", `${d}/cwmp.key`);\n}\n\nif (fsHostname) {\n  const FS_PORT = allConfig[\"FS_PORT\"] || 7567;\n  const FS_SSL = !!allConfig[\"FS_SSL_CERT\"];\n  setConfig(\n    \"FS_URL_PREFIX\",\n    (FS_SSL ? \"https\" : \"http\") + `://${fsHostname}:${FS_PORT}/`,\n  );\n}\n\n// Defaults\nfor (const [k, v] of Object.entries(options))\n  if (v[\"default\"] != null) setConfig(k, v[\"default\"]);\n\nexport function get(\n  optionName: string,\n  deviceId?: string,\n): string | number | boolean {\n  if (!deviceId) return allConfig[optionName];\n\n  optionName = `${optionName}-${deviceId}`;\n  let v = allConfig[optionName];\n  if (v != null) return v;\n\n  let i = optionName.lastIndexOf(\"-\");\n  v = allConfig[optionName.slice(0, i)];\n  if (v != null) return v;\n\n  i = optionName.lastIndexOf(\"-\", i - 1);\n  v = allConfig[optionName.slice(0, i)];\n  if (v != null) return v;\n\n  i = optionName.lastIndexOf(\"-\", i - 1);\n  v = allConfig[optionName.slice(0, i)];\n  if (v != null) return v;\n\n  i = optionName.lastIndexOf(\"-\", i - 1);\n  if (i > 0) {\n    v = allConfig[optionName.slice(0, i)];\n    if (v != null) return v;\n  }\n\n  return null;\n}\n\nexport function getDefault(optionName: string): string | number | boolean {\n  const option = options[optionName];\n  if (!option) return null;\n\n  let val = option[\"default\"];\n  if (val && option.type === \"path\") val = resolve(val);\n\n  return val;\n}\n"
  },
  {
    "path": "lib/connection-request.ts",
    "content": "import * as crypto from \"node:crypto\";\nimport * as dgram from \"node:dgram\";\nimport * as http from \"node:http\";\nimport Expression, { Value } from \"./common/expression.ts\";\nimport * as auth from \"./auth.ts\";\nimport * as extensions from \"./extensions.ts\";\nimport * as debug from \"./debug.ts\";\nimport XmppClient from \"./xmpp-client.ts\";\nimport * as config from \"../lib/config.ts\";\nimport { encodeEntities, parseAttrs, Element } from \"./xml-parser.ts\";\nimport * as logger from \"../lib/logger.ts\";\n\nasync function extractAuth(\n  exp: Expression,\n  dflt: Value,\n): Promise<[string, string, Expression]> {\n  let username: string, password: string;\n  const _exp = await exp.evaluateAsync(\n    async (e: Expression): Promise<Expression> => {\n      if (e instanceof Expression.Parameter)\n        return new Expression.Literal(null);\n      if (e instanceof Expression.FunctionCall) {\n        if (e.name === \"NOW\") return new Expression.Literal(0);\n        if (!username) {\n          if (e.name === \"EXT\") {\n            if (!e.args.every((a) => a instanceof Expression.Literal))\n              return new Expression.Literal(null);\n            const args = e.args.map((a) => a.value.toString());\n            if (typeof args[0] !== \"string\" || typeof args[1] !== \"string\")\n              return new Expression.Literal(null);\n\n            const { fault, value } = await extensions.run(args);\n            if (fault) return new Expression.Literal(null);\n            return new Expression.Literal(value);\n          } else if (e.name === \"AUTH\") {\n            if (e.args.every((a) => a instanceof Expression.Literal)) {\n              username = `${e.args[0].value ?? \"\"}`;\n              password = `${e.args[1].value ?? \"\"}`;\n            }\n            return new Expression.Literal(dflt);\n          }\n        }\n      }\n      return e;\n    },\n  );\n  return [username, password, _exp];\n}\n\nfunction httpGet(\n  url: URL,\n  options: http.RequestOptions,\n  _debug: boolean,\n  deviceId: string,\n): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders }> {\n  return new Promise((resolve, reject) => {\n    const req = http\n      .get(url, options, (res) => {\n        res.resume();\n        resolve({ statusCode: res.statusCode, headers: res.headers });\n        if (_debug) {\n          debug.outgoingHttpRequest(req, deviceId, \"GET\", url, null);\n          debug.incomingHttpResponse(res, deviceId, null);\n        }\n      })\n      .on(\"error\", (err) => {\n        req.destroy();\n        reject(err);\n        if (_debug)\n          debug.outgoingHttpRequestError(req, deviceId, \"GET\", url, err);\n      })\n      .on(\"timeout\", () => {\n        req.destroy();\n      });\n  });\n}\n\nexport async function httpConnectionRequest(\n  address: string,\n  authExp: Expression,\n  allowBasicAuth: boolean,\n  timeout: number,\n  _debug: boolean,\n  deviceId: string,\n): Promise<string> {\n  const url = new URL(address);\n  if (url.protocol !== \"http:\")\n    return \"Invalid connection request URL or protocol\";\n\n  const options: http.RequestOptions = {\n    agent: new http.Agent({ maxSockets: 1, keepAlive: true, timeout: timeout }),\n  };\n\n  let authHeader: Record<string, string>;\n  let username: string;\n  let password: string;\n\n  while (!authHeader || (username != null && password != null)) {\n    let opts = options;\n    if (authHeader) {\n      if (authHeader[\"method\"] === \"Basic\") {\n        if (!allowBasicAuth) return \"Basic HTTP authentication not allowed\";\n\n        opts = Object.assign(\n          {\n            headers: {\n              Authorization: auth.basic(username || \"\", password || \"\"),\n            },\n          },\n          options,\n        );\n      } else if (authHeader[\"method\"] === \"Digest\") {\n        opts = Object.assign(\n          {\n            headers: {\n              Authorization: auth.solveDigest(\n                username,\n                password,\n                url.pathname + url.search,\n                \"GET\",\n                null,\n                authHeader,\n              ),\n            },\n          },\n          options,\n        );\n      } else {\n        return \"Unrecognized auth method\";\n      }\n    }\n\n    let res: { statusCode: number; headers: http.IncomingHttpHeaders };\n    try {\n      res = await httpGet(url, opts, _debug, deviceId);\n    } catch (err) {\n      // Workaround for some devices unexpectedly closing the connection\n      if (authHeader) {\n        try {\n          res = await httpGet(url, opts, _debug, deviceId);\n        } catch (err) {\n          return `Connection request error: ${err.message}`;\n        }\n      }\n\n      if (err[\"code\"] === \"ECONNRESET\" || err[\"code\"] === \"ECONNREFUSED\")\n        return \"Device is offline\";\n\n      return `Connection request error: ${err.message}`;\n    }\n\n    if (res.statusCode === 200 || res.statusCode === 204) return \"\";\n\n    // When a Connection Request is received for the Virtual CWMP Device and the Proxied\n    // Device is offline the CPE Proxier MUST respond with an HTTP 503 failure\n    if (res.statusCode === 503) return \"Device is offline\";\n\n    if (res.statusCode === 401 && res.headers[\"www-authenticate\"]) {\n      try {\n        authHeader = auth.parseWwwAuthenticateHeader(\n          res.headers[\"www-authenticate\"],\n        );\n      } catch {\n        return \"Connection request error: Error parsing www-authenticate header\";\n      }\n      [username, password, authExp] = await extractAuth(authExp, false);\n    } else {\n      return `Connection request error: Unexpected status code ${res.statusCode}`;\n    }\n  }\n\n  return \"Connection request error: Incorrect connection request credentials\";\n}\n\nexport async function udpConnectionRequest(\n  host: string,\n  port: number,\n  authExp: Expression,\n  sourcePort = 0,\n  _debug: boolean,\n  deviceId: string,\n): Promise<void> {\n  const now = Date.now();\n\n  const client = dgram.createSocket({ type: \"udp4\", reuseAddr: true });\n  // When a device is NAT'ed, the UDP Connection Request must originate from\n  // the same address and port used by the STUN server, in order to traverse\n  // the firewall. This does require that the Genieacs NBI and STUN server\n  // are allowed to bind to the same address and port. The STUN server needs\n  // to open its UDP port with the SO_REUSEADDR option, allowing the NBI to\n  // also bind to the same port.\n  if (sourcePort) client.bind({ port: sourcePort, exclusive: true });\n\n  let username: string;\n  let password: string;\n\n  [username, password, authExp] = await extractAuth(authExp, null);\n\n  if (username == null) username = \"\";\n  if (password == null) password = \"\";\n  while (username != null && password != null) {\n    const ts = Math.trunc(now / 1000);\n    const id = Math.trunc(Math.random() * 4294967295);\n    const cn = crypto.randomBytes(8).toString(\"hex\");\n    const sig = crypto\n      .createHmac(\"sha1\", password)\n      .update(`${ts}${id}${username}${cn}`)\n      .digest(\"hex\");\n    const uri = `http://${host}:${port}?ts=${ts}&id=${id}&un=${username}&cn=${cn}&sig=${sig}`;\n    const msg = `GET ${uri} HTTP/1.1\\r\\nHost: ${host}:${port}\\r\\n\\r\\n`;\n    const message = Buffer.from(msg);\n\n    for (let i = 0; i < 3; ++i) {\n      await new Promise<void>((resolve, reject) => {\n        client.send(message, 0, message.length, port, host, (err: Error) => {\n          if (err) reject(err);\n          else resolve();\n          if (_debug) debug.outgoingUdpMessage(host, deviceId, port, msg);\n        });\n      });\n    }\n\n    [username, password, authExp] = await extractAuth(authExp, null);\n  }\n  client.close();\n}\n\nconst XMPP_JID = config.get(\"XMPP_JID\") as string;\nconst XMPP_PASSWORD = config.get(\"XMPP_PASSWORD\") as string;\nconst XMPP_RESOURCE = crypto.randomBytes(8).toString(\"hex\");\n\nlet xmppClient: XmppClient;\n\nfunction xmppClientOnError(err: Error): void {\n  xmppClient = null;\n  logger.error({\n    message: \"XMPP exception\",\n    exception: err,\n    pid: process.pid,\n  });\n}\n\nfunction xmppClientOnClose(): void {\n  xmppClient = null;\n}\n\nexport async function xmppConnectionRequest(\n  jid: string,\n  authExp: Expression,\n  timeout: number,\n  _debug: boolean,\n  deviceId: string,\n): Promise<string> {\n  if (!xmppClient) {\n    const [host, username] = XMPP_JID.split(\"@\").reverse();\n    xmppClient = await XmppClient.connect({\n      host,\n      username,\n      resource: XMPP_RESOURCE,\n      password: XMPP_PASSWORD,\n      timeout: 120000,\n    });\n    xmppClient.on(\"error\", xmppClientOnError);\n    xmppClient.on(\"close\", xmppClientOnClose);\n    xmppClient.unref();\n  }\n\n  let username: string;\n  let password: string;\n\n  [username, password, authExp] = await extractAuth(authExp, null);\n  while (username != null && password != null) {\n    const msg = `<connectionRequest xmlns=\"urn:broadband-forum-org:cwmp:xmppConnReq-1-0\"><username>${encodeEntities(\n      username,\n    )}</username><password>${encodeEntities(\n      password,\n    )}</password></connectionRequest>`;\n    let res: Element, rawRes: string, rawReq: string;\n    try {\n      ({ res, rawRes, rawReq } = await xmppClient.sendIqStanza(\n        XMPP_JID,\n        jid,\n        \"get\",\n        msg,\n        timeout,\n      ));\n    } catch (err) {\n      return err.message;\n    }\n    if (_debug) {\n      debug.outgoingXmppStanza(deviceId, rawReq);\n      debug.incomingXmppStanza(deviceId, rawRes);\n    }\n    const attrs = parseAttrs(res.attrs);\n    const type = attrs.find((a) => a.name === \"type\");\n    if (type && type.value === \"result\") return \"\";\n    const error = res.children.find((c) => c.name === \"error\");\n    if (!error || !error.children[0])\n      return \"Unexpected XMPP connection request response\";\n    if (error.children[0].name === \"service-unavailable\")\n      return \"Device is offline\";\n    if (error.children[0].name !== \"not-authorized\")\n      return \"Unexpected XMPP connection request response\";\n    [username, password, authExp] = await extractAuth(authExp, null);\n  }\n  return \"Incorrect connection request credentials\";\n}\n"
  },
  {
    "path": "lib/cwmp/db.ts",
    "content": "import { ObjectId } from \"mongodb\";\nimport { decodeTag, encodeTag, escapeRegExp } from \"../util.ts\";\nimport {\n  DeviceData,\n  Attributes,\n  SessionFault,\n  Task,\n  Operation,\n} from \"../types.ts\";\nimport { collections } from \"../db/db.ts\";\nimport { optimizeProjection } from \"../db/util.ts\";\nimport * as MongoTypes from \"../db/types.ts\";\n\nconst INVALID_PATH_SUFFIX = \"__invalid\";\n\nfunction compareAccessLists(list1: string[], list2: string[]): boolean {\n  if (list1.length !== list2.length) return false;\n  for (const [i, v] of list1.entries()) if (v !== list2[i]) return false;\n  return true;\n}\n\nexport async function fetchDevice(\n  id: string,\n  timestamp: number,\n): Promise<[string, number, Attributes?][]> {\n  const res: [string, number, Attributes?][] = [\n    [\"Events\", timestamp, { object: [timestamp, 1], writable: [timestamp, 0] }],\n    [\n      \"DeviceID\",\n      timestamp,\n      { object: [timestamp, 1], writable: [timestamp, 0] },\n    ],\n  ];\n\n  const device = (await collections.devices.findOne({ _id: id })) as any;\n  if (!device) return null;\n\n  function storeParams(\n    obj,\n    path: string,\n    pathLength: number,\n    ts: number,\n  ): void {\n    if (obj[\"_timestamp\"]) obj[\"_timestamp\"] = +obj[\"_timestamp\"];\n    if (obj[\"_attributesTimestamp\"])\n      obj[\"_attributesTimestamp\"] = +obj[\"_attributesTimestamp\"];\n\n    const attrs: Attributes = {};\n    let t = obj[\"_timestamp\"] || 1;\n    if (ts > t) t = ts;\n\n    if (obj[\"_value\"] != null) {\n      attrs.value = [obj[\"_timestamp\"] || 1, [obj[\"_value\"], obj[\"_type\"]]];\n      if (obj[\"_type\"] === \"xsd:dateTime\" && obj[\"_value\"] instanceof Date)\n        attrs.value[1][0] = +attrs.value[1][0];\n\n      obj[\"_object\"] = false;\n    }\n    if (obj[\"_writable\"] != null)\n      attrs.writable = [ts || 1, obj[\"_writable\"] ? 1 : 0];\n\n    if (obj[\"_object\"] != null) attrs.object = [t, obj[\"_object\"] ? 1 : 0];\n\n    if (obj[\"_notification\"] != null) {\n      attrs.notification = [\n        obj[\"_attributesTimestamp\"] || 1,\n        obj[\"_notification\"],\n      ];\n    }\n\n    if (obj[\"_accessList\"] != null)\n      attrs.accessList = [obj[\"_attributesTimestamp\"] || 1, obj[\"_accessList\"]];\n\n    try {\n      res.push([path, t, attrs]);\n    } catch {\n      // The path parser is now more strict so we might be in a situation where\n      // the database contains invalid paths from before this change So here we\n      // encode the invalid characters.\n      const splits = path.split(\".\");\n      splits[splits.length - 1] =\n        encodeTag(splits[splits.length - 1]) + INVALID_PATH_SUFFIX;\n      path = splits.join(\".\");\n      res.push([path, t, attrs]);\n      return;\n    }\n\n    for (const [k, v] of Object.entries(obj)) {\n      if (!k.startsWith(\"_\")) {\n        obj[\"_object\"] = true;\n        storeParams(v, `${path}.${k}`, pathLength + 1, obj[\"_timestamp\"]);\n      }\n    }\n\n    if (obj[\"_object\"] && obj[\"_timestamp\"])\n      res.push([path + \".*\", obj[\"_timestamp\"]]);\n  }\n\n  const ts: number = +device[\"_timestamp\"] || 0;\n  if (ts) res.push([\"*\", ts]);\n\n  for (const [k, v] of Object.entries(device)) {\n    switch (k) {\n      case \"_lastInform\":\n        res.push([\n          \"Events.Inform\",\n          +v,\n          {\n            object: [+v, 0],\n            writable: [+v, 0],\n            value: [+v, [+v, \"xsd:dateTime\"]],\n          },\n        ]);\n        break;\n      case \"_lastBoot\":\n        res.push([\n          \"Events.1_BOOT\",\n          +v,\n          {\n            object: [+v, 0],\n            writable: [+v, 0],\n            value: [+v, [+v, \"xsd:dateTime\"]],\n          },\n        ]);\n        break;\n      case \"_lastBootstrap\":\n        res.push([\n          \"Events.0_BOOTSTRAP\",\n          +v,\n          {\n            object: [+v, 0],\n            writable: [+v, 0],\n            value: [+v, [+v, \"xsd:dateTime\"]],\n          },\n        ]);\n        break;\n      case \"_registered\":\n        // Use current timestamp for registered event attribute timestamps\n        res.push([\n          \"Events.Registered\",\n          timestamp,\n          {\n            object: [timestamp, 0],\n            writable: [timestamp, 0],\n            value: [timestamp, [+v, \"xsd:dateTime\"]],\n          },\n        ]);\n        break;\n      case \"_id\":\n        res.push([\n          \"DeviceID.ID\",\n          timestamp,\n          {\n            object: [timestamp, 0],\n            writable: [timestamp, 0],\n            value: [timestamp, [v as string, \"xsd:string\"]],\n          },\n        ]);\n        break;\n      case \"_tags\":\n        if ((v as string[]).length) {\n          res.push([\n            \"Tags\",\n            timestamp,\n            { object: [timestamp, 1], writable: [timestamp, 0] },\n          ]);\n        }\n\n        for (const t of v as string[]) {\n          res.push([\n            \"Tags.\" + encodeTag(t),\n            timestamp,\n            {\n              object: [timestamp, 0],\n              writable: [timestamp, 1],\n              value: [timestamp, [true, \"xsd:boolean\"]],\n            },\n          ]);\n        }\n        break;\n      case \"_deviceId\":\n        if (v[\"_Manufacturer\"] != null) {\n          res.push([\n            \"DeviceID.Manufacturer\",\n            timestamp,\n            {\n              object: [timestamp, 0],\n              writable: [timestamp, 0],\n              value: [timestamp, [v[\"_Manufacturer\"], \"xsd:string\"]],\n            },\n          ]);\n        }\n\n        if (v[\"_OUI\"] != null) {\n          res.push([\n            \"DeviceID.OUI\",\n            timestamp,\n            {\n              object: [timestamp, 0],\n              writable: [timestamp, 0],\n              value: [timestamp, [v[\"_OUI\"], \"xsd:string\"]],\n            },\n          ]);\n        }\n\n        if (v[\"_ProductClass\"] != null) {\n          res.push([\n            \"DeviceID.ProductClass\",\n            timestamp,\n            {\n              object: [timestamp, 0],\n              writable: [timestamp, 0],\n              value: [timestamp, [v[\"_ProductClass\"], \"xsd:string\"]],\n            },\n          ]);\n        }\n\n        if (v[\"_SerialNumber\"] != null) {\n          res.push([\n            \"DeviceID.SerialNumber\",\n            timestamp,\n            {\n              object: [timestamp, 0],\n              writable: [timestamp, 0],\n              value: [timestamp, [v[\"_SerialNumber\"], \"xsd:string\"]],\n            },\n          ]);\n        }\n        break;\n      default:\n        if (!k.startsWith(\"_\")) storeParams(v, k, 1, ts);\n    }\n  }\n  return res;\n}\n\nexport async function saveDevice(\n  deviceId: string,\n  deviceData: DeviceData,\n  isNew: boolean,\n  sessionTimestamp: number,\n): Promise<void> {\n  const update = { $set: {}, $unset: {}, $addToSet: {}, $pull: {} };\n\n  for (const diff of deviceData.timestamps.diff()) {\n    if (diff[0].wildcard !== 1 << (diff[0].length - 1)) continue;\n\n    if (\n      diff[0].segments[0] === \"Events\" ||\n      diff[0].segments[0] === \"DeviceID\" ||\n      diff[0].segments[0] === \"Tags\"\n    )\n      continue;\n\n    const parent = deviceData.paths.get(diff[0].slice(0, -1).toString());\n\n    // Param timestamps may be greater than session timestamp to track revisions\n    if (diff[2] > sessionTimestamp) diff[2] = sessionTimestamp;\n\n    if (diff[2] == null && diff[1] != null) {\n      update[\"$unset\"][\n        parent.length ? parent.toString() + \"._timestamp\" : \"_timestamp\"\n      ] = 1;\n    } else {\n      if (parent && (!parent.length || deviceData.attributes.has(parent))) {\n        update[\"$set\"][\n          parent.length ? parent.toString() + \"._timestamp\" : \"_timestamp\"\n        ] = new Date(diff[2]);\n      }\n    }\n  }\n\n  for (const diff of deviceData.attributes.diff()) {\n    const path = diff[0];\n    const value1 = (((diff[1] || {}).value || [])[1] || [])[0];\n    const value2 = (((diff[2] || {}).value || [])[1] || [])[0];\n    const valueType1 = (((diff[1] || {}).value || [])[1] || [])[1];\n    const valueType2 = (((diff[2] || {}).value || [])[1] || [])[1];\n    const valueTimestamp1 = ((diff[1] || {}).value || [])[0];\n    const valueTimestamp2 = ((diff[2] || {}).value || [])[0];\n    const object1 = ((diff[1] || {}).object || [])[1];\n    const object2 = ((diff[2] || {}).object || [])[1];\n    const writable2 = ((diff[2] || {}).writable || [])[1];\n    const writable1 = ((diff[1] || {}).writable || [])[1];\n    const attributesTimestamp1 = ((diff[1] || {}).notification || [])[0];\n    const attributesTimestamp2 = ((diff[2] || {}).notification || [])[0];\n    const notification1 = ((diff[1] || {}).notification || [])[1];\n    const notification2 = ((diff[2] || {}).notification || [])[1];\n    const accessList1 = ((diff[1] || {}).accessList || [])[1];\n    const accessList2 = ((diff[2] || {}).accessList || [])[1];\n\n    switch (path.segments[0]) {\n      case \"Events\":\n        if (path.length === 2 && value2 !== value1) {\n          if (!diff[2]) {\n            switch (path.segments[1]) {\n              case \"Inform\":\n                update[\"$unset\"][\"_lastInform\"] = 1;\n                break;\n              case \"1_BOOT\":\n                update[\"$unset\"][\"_lastBoot\"] = 1;\n                break;\n              case \"0_BOOTSTRAP\":\n                update[\"$unset\"][\"_lastBootstrap\"] = 1;\n                break;\n              case \"Registered\":\n                update[\"$unset\"][\"_registered\"] = 1;\n            }\n          } else {\n            const t = new Date(diff[2].value[1][0] as number);\n            switch (path.segments[1]) {\n              case \"Inform\":\n                update[\"$set\"][\"_lastInform\"] = t;\n                break;\n              case \"1_BOOT\":\n                update[\"$set\"][\"_lastBoot\"] = t;\n                break;\n              case \"0_BOOTSTRAP\":\n                update[\"$set\"][\"_lastBootstrap\"] = t;\n                break;\n              case \"Registered\":\n                update[\"$set\"][\"_registered\"] = t;\n            }\n          }\n        }\n\n        break;\n      case \"DeviceID\":\n        if (value2 !== value1) {\n          const v = diff[2].value[1][0];\n          switch (path.segments[1]) {\n            case \"ID\":\n              update[\"$set\"][\"_id\"] = v;\n              break;\n            case \"Manufacturer\":\n              update[\"$set\"][\"_deviceId._Manufacturer\"] = v;\n              break;\n            case \"OUI\":\n              update[\"$set\"][\"_deviceId._OUI\"] = v;\n              break;\n            case \"ProductClass\":\n              update[\"$set\"][\"_deviceId._ProductClass\"] = v;\n              break;\n            case \"SerialNumber\":\n              update[\"$set\"][\"_deviceId._SerialNumber\"] = v;\n          }\n        }\n        break;\n      case \"Tags\":\n        if (value2 !== value1) {\n          if (value2 != null) {\n            if (!update[\"$addToSet\"][\"_tags\"])\n              update[\"$addToSet\"][\"_tags\"] = { $each: [] };\n            update[\"$addToSet\"][\"_tags\"][\"$each\"].push(\n              decodeTag(path.segments[1] as string),\n            );\n          } else {\n            if (!update[\"$pull\"][\"_tags\"]) {\n              update[\"$pull\"][\"_tags\"] = {\n                $in: [],\n              };\n            }\n            update[\"$pull\"][\"_tags\"][\"$in\"].push(\n              decodeTag(path.segments[1] as string),\n            );\n          }\n        }\n\n        break;\n      default:\n        if (!diff[2]) {\n          let pathStr = path.toString();\n          // Paths with that suffix are encoded and need to be decoded\n          if (pathStr.endsWith(INVALID_PATH_SUFFIX)) {\n            const splits = pathStr.split(\".\");\n            splits[splits.length - 1] = decodeTag(\n              splits[splits.length - 1].slice(\n                0,\n                0 - INVALID_PATH_SUFFIX.length,\n              ),\n            );\n            pathStr = splits.join(\".\");\n          }\n          update[\"$unset\"][pathStr] = 1;\n          continue;\n        }\n\n        for (const attrName of Object.keys(diff[2])) {\n          // Param timestamps may be greater than session timestamp to track revisions\n          if (diff[2][attrName][0] > sessionTimestamp)\n            diff[2][attrName][0] = sessionTimestamp;\n\n          if (diff[2][attrName][1] != null) {\n            switch (attrName) {\n              case \"value\":\n                if (value2 !== value1) {\n                  if (\n                    valueType2 === \"xsd:dateTime\" &&\n                    Number.isInteger(value2 as number)\n                  ) {\n                    update[\"$set\"][path.toString() + \"._value\"] = new Date(\n                      value2 as number,\n                    );\n                  } else {\n                    update[\"$set\"][path.toString() + \"._value\"] = value2;\n                  }\n                }\n\n                if (valueType2 !== valueType1)\n                  update[\"$set\"][path.toString() + \"._type\"] = valueType2;\n\n                if (valueTimestamp2 !== valueTimestamp1) {\n                  update[\"$set\"][path.toString() + \"._timestamp\"] = new Date(\n                    valueTimestamp2,\n                  );\n                }\n\n                break;\n              case \"object\":\n                if (!diff[1]?.object || object2 !== object1) {\n                  update[\"$set\"][\n                    path.length ? path.toString() + \"._object\" : \"_object\"\n                  ] = !!object2;\n                }\n\n                break;\n              case \"writable\":\n                if (!diff[1]?.writable || writable2 !== writable1) {\n                  update[\"$set\"][\n                    path.length ? path.toString() + \"._writable\" : \"_writable\"\n                  ] = !!writable2;\n                }\n\n                break;\n              case \"notification\":\n                if (\n                  !diff[1] ||\n                  !diff[1].notification ||\n                  notification2 !== notification1\n                ) {\n                  update[\"$set\"][\n                    path.length\n                      ? path.toString() + \"._notification\"\n                      : \"_notification\"\n                  ] = notification2;\n                }\n\n                if (attributesTimestamp2 !== attributesTimestamp1) {\n                  update[\"$set\"][path.toString() + \"._attributesTimestamp\"] =\n                    new Date(attributesTimestamp2);\n                }\n\n                break;\n              case \"accessList\":\n                if (\n                  !diff[1] ||\n                  !diff[1].accessList ||\n                  !compareAccessLists(accessList2, accessList1)\n                ) {\n                  update[\"$set\"][\n                    path.length\n                      ? path.toString() + \"._accessList\"\n                      : \"_accessList\"\n                  ] = accessList2;\n                }\n\n                if (attributesTimestamp2 !== attributesTimestamp1) {\n                  update[\"$set\"][path.toString() + \"._attributesTimestamp\"] =\n                    new Date(attributesTimestamp2);\n                }\n            }\n          }\n        }\n\n        if (diff[1]) {\n          for (const attrName of Object.keys(diff[1])) {\n            if (\n              diff[1][attrName][1] != null &&\n              diff[2]?.[attrName]?.[1] == null\n            ) {\n              const p = path.length ? path.toString() + \".\" : \"\";\n              update[\"$unset\"][`${p}_${attrName}`] = 1;\n              if (attrName === \"value\") {\n                update[\"$unset\"][p + \"_type\"] = 1;\n                update[\"$unset\"][p + \"_timestamp\"] = 1;\n              } else if (attrName === \"notification\") {\n                if (accessList2 == null)\n                  update[\"$unset\"][`${p}_attributesTimestamp`] = 1;\n              } else if (attrName === \"accessList\") {\n                if (notification2 == null)\n                  update[\"$unset\"][`${p}_attributesTimestamp`] = 1;\n              }\n            }\n          }\n        }\n    }\n  }\n\n  update[\"$unset\"] = optimizeProjection(update[\"$unset\"]);\n\n  // Remove overlap possibly caused by parameters changing from objects\n  // to regular parameters or vice versa. Reason being that _timestamp\n  // represents two different things depending on whether the parameter\n  // is an object or not.\n  for (const k of Object.keys(update[\"$unset\"]))\n    if (update[\"$set\"][k] != null) delete update[\"$unset\"][k];\n\n  // Remove empty keys\n  for (const [k, v] of Object.entries(update)) {\n    if (k === \"$addToSet\") {\n      for (const [kk, vv] of Object.entries(v))\n        if (!vv[\"$each\"].length) delete v[kk];\n    } else if (k === \"$pull\") {\n      for (const [kk, vv] of Object.entries(v))\n        if (!vv[\"$in\"].length) delete v[kk];\n    }\n    if (!Object.keys(v).length) delete update[k];\n  }\n\n  if (!Object.keys(update).length) return;\n\n  // Mongo doesn't allow $addToSet and $pull at the same time\n  let update2;\n  if (update[\"$addToSet\"] && update[\"$pull\"]) {\n    update2 = { $pull: update[\"$pull\"] };\n    delete update[\"$pull\"];\n  }\n\n  const result = await collections.devices.updateOne(\n    { _id: deviceId },\n    update,\n    {\n      upsert: isNew,\n    },\n  );\n\n  if (!result.matchedCount && !result.upsertedCount)\n    throw new Error(`Device ${deviceId} not found in database`);\n\n  if (update2) {\n    await collections.devices.updateOne({ _id: deviceId }, update2);\n    return;\n  }\n}\n\nexport async function getFaults(\n  deviceId: string,\n): Promise<{ [channel: string]: SessionFault }> {\n  const res = await collections.faults\n    .find({ _id: { $regex: `^${escapeRegExp(deviceId)}\\\\:` } })\n    .toArray();\n\n  const faults: { [channel: string]: SessionFault } = {};\n  for (const r of res) {\n    const channel = r._id.slice(deviceId.length + 1);\n    const fault: SessionFault = {\n      code: r.code,\n      message: r.message,\n      ...(r.detail && { detail: r.detail }),\n      timestamp: +r.timestamp,\n      provisions: JSON.parse(r.provisions),\n      retries: r.retries,\n      ...(r.expiry && { expiry: +r.expiry }),\n    };\n    faults[channel] = fault;\n  }\n\n  return faults;\n}\n\nexport async function saveFault(\n  deviceId: string,\n  channel: string,\n  fault: SessionFault,\n): Promise<void> {\n  const id = `${deviceId}:${channel}`;\n  const f: MongoTypes.Fault = {\n    _id: id,\n    device: deviceId,\n    channel: channel,\n    timestamp: new Date(fault.timestamp),\n    code: fault.code,\n    message: fault.message,\n    ...(fault.detail && { detail: fault.detail }),\n    retries: fault.retries,\n    ...(fault.expiry && { expiry: new Date(fault.expiry) }),\n    provisions: JSON.stringify(fault.provisions),\n  };\n  await collections.faults.replaceOne({ _id: id }, f, { upsert: true });\n}\n\nexport async function deleteFault(\n  deviceId: string,\n  channel: string,\n): Promise<void> {\n  await collections.faults.deleteOne({ _id: `${deviceId}:${channel}` });\n}\n\nexport async function getDueTasks(\n  deviceId: string,\n  timestamp: number,\n): Promise<[Task[], number]> {\n  const cur = collections.tasks\n    .find({ device: deviceId })\n    .sort({ timestamp: 1 });\n  const tasks = [] as Task[];\n\n  for await (const t of cur) {\n    if (+t.timestamp >= timestamp) return [tasks, +t.timestamp];\n    const task: Task = {\n      _id: t._id.toString(),\n      name: t.name,\n      ...(t.timestamp && { timestamp: +t.timestamp }),\n      ...(t.expiry && { expiry: +t.expiry }),\n      ...(t.name === \"getParameterValues\" && {\n        parameterNames: t.parameterNames,\n      }),\n      ...(t.name === \"setParameterValues\" && {\n        parameterValues: t.parameterValues,\n      }),\n      ...(t.name === \"refreshObject\" && {\n        objectName: t.objectName,\n      }),\n      ...(t.name === \"download\" && {\n        fileType: t.fileType,\n        fileName: t.fileName,\n        targetFileName: t.targetFileName,\n      }),\n      ...(t.name === \"addObject\" && {\n        objectName: t.objectName,\n        parameterValues: t.parameterValues,\n      }),\n      ...(t.name === \"deleteObject\" && {\n        objectName: t.objectName,\n      }),\n      ...(t.name === \"provisions\" && {\n        provisions: t.provisions,\n      }),\n    };\n\n    tasks.push(task);\n\n    // For API compatibility\n    if (task.name === \"download\" && t[\"file\"]) {\n      let q;\n      if (ObjectId.isValid(t[\"file\"]))\n        q = { _id: { $in: [t[\"file\"], new ObjectId(t[\"file\"])] } };\n      else q = { _id: t[\"file\"] };\n\n      const res = await collections.files.find(q).toArray();\n\n      if (res[0]) {\n        if (!task.fileType) task.fileType = res[0].metadata.fileType;\n\n        if (!task.fileName)\n          task.fileName = res[0].filename || res[0]._id.toString();\n      }\n    }\n  }\n  return [tasks, null];\n}\n\nexport async function clearTasks(\n  deviceId: string,\n  taskIds: string[],\n): Promise<void> {\n  await collections.tasks.deleteMany({\n    _id: { $in: taskIds.map((id) => new ObjectId(id)) },\n  });\n}\n\nexport async function getOperations(\n  deviceId: string,\n): Promise<{ [commandKey: string]: Operation }> {\n  const res = await collections.operations\n    .find({ _id: { $regex: `^${escapeRegExp(deviceId)}\\\\:` } })\n    .toArray();\n\n  const operations: { [commandKey: string]: Operation } = {};\n  for (const r of res) {\n    const commandKey = r._id.slice(deviceId.length + 1);\n    // Workaround for a bug in v1.2.1 where operation object is saved without deserialization\n    if (typeof r.provisions !== \"string\") {\n      delete r._id;\n      operations[commandKey] = r as unknown as Operation;\n      continue;\n    }\n    const operation: Operation = {\n      name: r.name,\n      timestamp: +r.timestamp,\n      channels:\n        typeof r.channels === \"string\" ? JSON.parse(r.channels) : r.channels,\n      retries: JSON.parse(r.retries),\n      provisions: JSON.parse(r.provisions),\n      ...(r.args && { args: JSON.parse(r.args) }),\n    };\n    operations[commandKey] = operation;\n  }\n  return operations;\n}\n\nexport async function saveOperation(\n  deviceId: string,\n  commandKey: string,\n  operation: Operation,\n): Promise<void> {\n  const id = `${deviceId}:${commandKey}`;\n  const o: MongoTypes.Operation = {\n    _id: id,\n    name: operation.name,\n    timestamp: new Date(operation.timestamp),\n    channels: JSON.stringify(operation.channels),\n    provisions: JSON.stringify(operation.provisions),\n    retries: JSON.stringify(operation.retries),\n    args: JSON.stringify(operation.args),\n  };\n  await collections.operations.replaceOne({ _id: id }, o, {\n    upsert: true,\n  });\n}\n\nexport async function deleteOperation(\n  deviceId: string,\n  commandKey: string,\n): Promise<void> {\n  await collections.operations.deleteOne({ _id: `${deviceId}:${commandKey}` });\n}\n"
  },
  {
    "path": "lib/cwmp/local-cache.ts",
    "content": "import * as vm from \"node:vm\";\nimport * as crypto from \"node:crypto\";\nimport { collections } from \"../db/db.ts\";\nimport { convertOldPrecondition } from \"../db/util.ts\";\nimport * as logger from \"../logger.ts\";\nimport * as scheduling from \"../scheduling.ts\";\nimport Expression, { Value } from \"../common/expression.ts\";\nimport {\n  Preset,\n  Provisions,\n  VirtualParameters,\n  Files,\n  Config,\n} from \"../types.ts\";\nimport { LocalCache } from \"../local-cache.ts\";\n\ninterface Snapshot {\n  presets: Preset[];\n  provisions: Provisions;\n  virtualParameters: VirtualParameters;\n  files: Files;\n  config: Config;\n}\n\nfunction flattenObject<T extends Record<keyof T, unknown>>(\n  src: T,\n  prefix = \"\",\n  dst = {} as T,\n): T {\n  for (const k of Object.keys(src)) {\n    const v = src[k];\n    if (typeof v === \"object\" && !Array.isArray(v))\n      flattenObject(v as T, `${prefix}${k}.`, dst);\n    else dst[`${prefix}${k}`] = v;\n  }\n  return dst;\n}\n\nasync function fetchPresets(): Promise<[string, Preset[]]> {\n  const res = await collections.presets.find().toArray();\n  let objects = await collections.objects.find().toArray();\n  res.sort((a, b) => (a._id > b._id ? 1 : -1));\n  objects.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto\n    .createHash(\"md5\")\n    .update(JSON.stringify(res))\n    .update(JSON.stringify(objects))\n    .digest(\"hex\");\n\n  objects = objects.map((obj) => {\n    // Flatten object\n    obj = flattenObject(obj);\n\n    // If no keys are defined, consider all parameters as keys to keep the\n    // same behavior from v1.0\n    if (!obj[\"_keys\"]?.length)\n      obj[\"_keys\"] = Object.keys(obj).filter((k) => !k.startsWith(\"_\"));\n\n    return obj;\n  });\n\n  res.sort((a, b) => {\n    if (a[\"weight\"] === b[\"weight\"])\n      return a[\"_id\"] > b[\"_id\"] ? 1 : a[\"_id\"] < b[\"_id\"] ? -1 : 0;\n    else return a[\"weight\"] - b[\"weight\"];\n  });\n\n  const presets = [] as Preset[];\n  for (const preset of res) {\n    let schedule: { md5: string; duration: number; schedule: any } = null;\n    if (preset[\"schedule\"]) {\n      const parts = preset[\"schedule\"].trim().split(/\\s+/);\n      schedule = {\n        md5: crypto.createHash(\"md5\").update(preset[\"schedule\"]).digest(\"hex\"),\n        duration: null,\n        schedule: null,\n      };\n\n      try {\n        schedule.duration = +parts.shift() * 1000;\n        schedule.schedule = scheduling.parseCron(parts.join(\" \"));\n      } catch {\n        logger.warn({\n          message: \"Invalid preset schedule\",\n          preset: preset[\"_id\"],\n          schedule: preset[\"schedule\"],\n        });\n        schedule.schedule = false;\n      }\n    }\n\n    const events = preset[\"events\"] || {};\n    let precondition: Expression = new Expression.Literal(true);\n    if (preset[\"precondition\"]) {\n      try {\n        precondition = Expression.parse(preset[\"precondition\"]);\n      } catch {\n        precondition = convertOldPrecondition(\n          JSON.parse(preset[\"precondition\"]),\n        );\n      }\n\n      // Simplify expression\n      precondition = precondition.evaluate((e) => e);\n    }\n\n    const _provisions: Preset[\"provisions\"] = [];\n\n    // Generate provisions from the old configuration format\n    for (const c of preset.configurations) {\n      switch (c.type) {\n        case \"age\":\n          _provisions.push([\n            \"refresh\",\n            new Expression.Literal(c.name),\n            new Expression.Literal(+c.age),\n          ]);\n          break;\n\n        case \"value\":\n          _provisions.push([\n            \"value\",\n            new Expression.Literal(c.name),\n            new Expression.Literal(c.value),\n          ]);\n          break;\n\n        case \"add_tag\":\n          _provisions.push([\n            \"tag\",\n            new Expression.Literal(c.tag),\n            new Expression.Literal(true),\n          ]);\n          break;\n\n        case \"delete_tag\":\n          _provisions.push([\n            \"tag\",\n            new Expression.Literal(c.tag),\n            new Expression.Literal(false),\n          ]);\n          break;\n\n        case \"provision\":\n          _provisions.push([\n            c.name,\n            ...(c.args || []).map((a) => Expression.parse(a)),\n          ]);\n          break;\n\n        case \"add_object\":\n          for (const obj of objects) {\n            if (obj[\"_id\"] === c.object) {\n              const alias = obj[\"_keys\"]\n                .map((k) => `${k}:${JSON.stringify(obj[k])}`)\n                .join(\",\");\n              const p = `${c.name}.[${alias}]`;\n              _provisions.push([\n                \"instances\",\n                new Expression.Literal(p),\n                new Expression.Literal(1),\n              ]);\n\n              for (const k in obj) {\n                if (!k.startsWith(\"_\") && !(obj[\"_keys\"].indexOf(k) !== -1))\n                  _provisions.push([\n                    \"value\",\n                    new Expression.Literal(`${p}.${k}`),\n                    new Expression.Literal(obj[k]),\n                  ]);\n              }\n            }\n          }\n\n          break;\n\n        case \"delete_object\":\n          for (const obj of objects) {\n            if (obj[\"_id\"] === c.object) {\n              const alias = obj[\"_keys\"]\n                .map((k) => `${k}:${JSON.stringify(obj[k])}`)\n                .join(\",\");\n              const p = `${c.name}.[${alias}]`;\n              _provisions.push([\n                \"instances\",\n                new Expression.Literal(p),\n                new Expression.Literal(0),\n              ]);\n            }\n          }\n\n          break;\n\n        default: {\n          const exhaustiveCheck: never = c;\n          throw new Error(\n            `Unknown configuration type ${exhaustiveCheck[\"type\"]}`,\n          );\n        }\n      }\n    }\n\n    presets.push({\n      name: preset[\"_id\"],\n      channel: (preset[\"channel\"] as string) || \"default\",\n      schedule: schedule,\n      events: events,\n      precondition: precondition,\n      provisions: _provisions,\n    });\n  }\n\n  return [h, presets];\n}\n\nasync function fetchProvisions(): Promise<[string, Provisions]> {\n  const res = await collections.provisions.find().toArray();\n  res.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto.createHash(\"md5\").update(JSON.stringify(res)).digest(\"hex\");\n\n  const provisions = {};\n  for (const r of res) {\n    provisions[r._id] = {};\n    provisions[r._id].md5 = crypto\n      .createHash(\"md5\")\n      .update(r.script)\n      .digest(\"hex\");\n    provisions[r._id].script = new vm.Script(\n      `\"use strict\";(function(){\\n${r.script}\\n})();`,\n      { filename: r._id, lineOffset: -1 },\n    );\n  }\n\n  return [h, provisions];\n}\n\nasync function fetchVirtualParameters(): Promise<[string, VirtualParameters]> {\n  const res = await collections.virtualParameters.find().toArray();\n  res.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto.createHash(\"md5\").update(JSON.stringify(res)).digest(\"hex\");\n\n  const virtualParameters = {};\n  for (const r of res) {\n    virtualParameters[r._id] = {};\n    virtualParameters[r._id].md5 = crypto\n      .createHash(\"md5\")\n      .update(r.script)\n      .digest(\"hex\");\n    virtualParameters[r._id].script = new vm.Script(\n      `\"use strict\";(function(){\\n${r.script}\\n})();`,\n      { filename: r._id, lineOffset: -1 },\n    );\n  }\n\n  return [h, virtualParameters];\n}\n\nasync function fetchFiles(): Promise<[string, Files]> {\n  const res = await collections.files.find().toArray();\n  res.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto.createHash(\"md5\").update(JSON.stringify(res)).digest(\"hex\");\n  const files = {};\n\n  for (const r of res) {\n    const id = r.filename || r._id.toString();\n    files[id] = {};\n    files[id].length = r.length;\n  }\n\n  return [h, files];\n}\n\nasync function fetchConfig(): Promise<[string, Config]> {\n  const conf = await collections.config.find().toArray();\n  conf.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto.createHash(\"md5\").update(JSON.stringify(conf)).digest(\"hex\");\n\n  const _config = {};\n\n  for (const c of conf) {\n    // Evaluate expressions to simplify them\n    _config[c._id] = Expression.parse(c.value).evaluate((e) => e);\n  }\n\n  return [h, _config];\n}\n\nconst localCache = new LocalCache<Snapshot>(\"cwmp-local-cache-hash\", refresh);\n\nasync function refresh(): Promise<[string, Snapshot]> {\n  const res = await Promise.all([\n    fetchPresets(),\n    fetchProvisions(),\n    fetchVirtualParameters(),\n    fetchFiles(),\n    fetchConfig(),\n  ]);\n\n  const h = crypto.createHash(\"md5\");\n  for (const r of res) h.update(r[0]);\n\n  const snapshot = {\n    presets: res[0][1],\n    provisions: res[1][1],\n    virtualParameters: res[2][1],\n    files: res[3][1],\n    config: res[4][1],\n  };\n\n  return [h.digest(\"hex\"), snapshot];\n}\n\nexport async function getRevision(): Promise<string> {\n  return await localCache.getRevision();\n}\n\nexport function getPresets(revision: string): Preset[] {\n  return localCache.get(revision).presets;\n}\n\nexport function getProvisions(revision: string): Provisions {\n  return localCache.get(revision).provisions;\n}\n\nexport function getVirtualParameters(revision: string): VirtualParameters {\n  return localCache.get(revision).virtualParameters;\n}\n\nexport function getFiles(revision: string): Files {\n  return localCache.get(revision).files;\n}\n\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: string,\n  fn: (e: Expression) => Expression.Literal,\n): string;\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: number,\n  fn: (e: Expression) => Expression.Literal,\n): number;\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: boolean,\n  fn: (e: Expression) => Expression.Literal,\n): boolean;\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: Value,\n  fn: (e: Expression) => Expression.Literal,\n): Value {\n  const snapshot = localCache.get(revision);\n  if (!snapshot) throw new Error(\"Cache snapshot does not exist\");\n\n  const e = snapshot.config[key];\n  if (!e) return dflt;\n  const v = e.evaluate(fn).value;\n  if (typeof v !== typeof dflt) return dflt;\n  return v;\n}\n\nexport function getConfigExpression(\n  snapshotKey: string,\n  key: string,\n): Expression {\n  const snapshot = localCache.get(snapshotKey);\n  return snapshot.config[key];\n}\n"
  },
  {
    "path": "lib/cwmp.ts",
    "content": "import * as zlib from \"node:zlib\";\nimport * as crypto from \"node:crypto\";\nimport { Socket } from \"node:net\";\nimport { IncomingMessage, ServerResponse } from \"node:http\";\nimport { pipeline, Readable } from \"node:stream\";\nimport { promisify } from \"node:util\";\nimport { decode, encodingExists } from \"iconv-lite\";\nimport * as auth from \"./auth.ts\";\nimport * as config from \"./config.ts\";\nimport { generateDeviceId, once, setTimeoutPromise } from \"./util.ts\";\nimport * as soap from \"./soap.ts\";\nimport * as session from \"./session.ts\";\nimport Expression, { Value } from \"./common/expression.ts\";\nimport * as cache from \"./cache.ts\";\nimport * as lock from \"./lock.ts\";\nimport * as localCache from \"./cwmp/local-cache.ts\";\nimport {\n  clearTasks,\n  deleteFault,\n  deleteOperation,\n  fetchDevice,\n  getDueTasks,\n  getFaults,\n  getOperations,\n  saveDevice,\n  saveFault,\n  saveOperation,\n} from \"./cwmp/db.ts\";\nimport * as logger from \"./logger.ts\";\nimport * as scheduling from \"./scheduling.ts\";\nimport Path from \"./common/path.ts\";\nimport * as extensions from \"./extensions.ts\";\nimport {\n  SessionContext,\n  AcsRequest,\n  SessionFault,\n  Fault,\n  SoapMessage,\n  InformRequest,\n  Preset,\n  GetRPCMethodsResponse,\n  CpeFault,\n} from \"./types.ts\";\nimport { parseXmlDeclaration } from \"./xml-parser.ts\";\nimport * as debug from \"./debug.ts\";\nimport { getRequestOrigin } from \"./forwarded.ts\";\nimport { getSocketEndpoints } from \"./server.ts\";\n\nconst gzipPromisified = promisify(zlib.gzip);\nconst deflatePromisified = promisify(zlib.deflate);\n\nconst REALM = \"GenieACS\";\nconst MAX_CYCLES = 4;\nconst MAX_CONCURRENT_REQUESTS = +config.get(\"MAX_CONCURRENT_REQUESTS\");\n\nconst MAX_SESSION_DURATION = 300000;\nconst LOCK_REFRESH_INTERVAL = 10000;\nexport const REQUEST_TIMEOUT = 10000;\n\nconst currentSessions = new WeakMap<Socket, SessionContext>();\nconst sessionsNonces = new WeakMap<Socket, string>();\n\nconst stats = {\n  concurrentRequests: 0,\n  totalRequests: 0,\n  droppedRequests: 0,\n  initiatedSessions: 0,\n};\n\nasync function authenticate(\n  sessionContext: SessionContext,\n  body: string,\n): Promise<boolean> {\n  const authExpression: Expression = localCache.getConfigExpression(\n    sessionContext.cacheSnapshot,\n    \"cwmp.auth\",\n  );\n  if (!authExpression) return true;\n\n  let authentication;\n\n  if (sessionContext.httpRequest.headers[\"authorization\"]) {\n    try {\n      authentication = auth.parseAuthorizationHeader(\n        sessionContext.httpRequest.headers[\"authorization\"],\n      );\n    } catch {\n      return false;\n    }\n  }\n\n  if (authentication?.method === \"Digest\") {\n    const sessionNonce = sessionsNonces.get(sessionContext.httpRequest.socket);\n\n    if (\n      !sessionNonce ||\n      authentication.nonce !== sessionNonce ||\n      (authentication.qop && (!authentication.cnonce || !authentication.nc))\n    )\n      return false;\n\n    authentication[\"body\"] = body;\n  }\n\n  const res = await authExpression.evaluateAsync(\n    async (e: Expression): Promise<Expression> => {\n      e = session.configContextCallback(sessionContext, e);\n      if (e instanceof Expression.Parameter)\n        return new Expression.Literal(null);\n\n      if (e instanceof Expression.FunctionCall) {\n        if (e.name === \"NOW\")\n          return new Expression.Literal(sessionContext.timestamp);\n        if (e.name === \"EXT\") {\n          if (!e.args.every((a) => a instanceof Expression.Literal))\n            return new Expression.Literal(null);\n\n          const args = e.args.map((a) => a.value.toString());\n          if (typeof args[0] !== \"string\" || typeof args[1] !== \"string\")\n            return new Expression.Literal(null);\n\n          const { fault, value } = await extensions.run(args);\n          if (fault) return new Expression.Literal(null);\n          return new Expression.Literal(value);\n        } else if (e.name === \"AUTH\") {\n          if (e.args.every((a) => a instanceof Expression.Literal)) {\n            const username = e.args[0].value;\n            const password = e.args[1].value;\n            if (username != null && password != null && authentication) {\n              if (authentication[\"method\"] === \"Basic\") {\n                return new Expression.Literal(\n                  authentication[\"username\"] === username.toString() &&\n                    authentication[\"password\"] === password.toString(),\n                );\n              }\n\n              if (authentication[\"method\"] === \"Digest\") {\n                const expected = auth.digest(\n                  username.toString(),\n                  REALM,\n                  password.toString(),\n                  authentication[\"nonce\"],\n                  \"POST\",\n                  authentication[\"uri\"],\n                  authentication[\"qop\"],\n                  body,\n                  authentication[\"cnonce\"],\n                  authentication[\"nc\"],\n                );\n                return new Expression.Literal(\n                  expected === authentication[\"response\"],\n                );\n              }\n            }\n          }\n          return new Expression.Literal(false);\n        }\n      }\n      return e;\n    },\n  );\n\n  if (res instanceof Expression.Literal) return !!res.value;\n\n  return false;\n}\n\nasync function writeResponse(\n  sessionContext: SessionContext,\n  res,\n  close = false,\n): Promise<void> {\n  // Close connection after last request in session\n  if (close) res.headers[\"Connection\"] = \"close\";\n\n  let data = res.data;\n\n  // Respond using the same content-encoding as the request\n  if (\n    sessionContext.httpRequest.headers[\"content-encoding\"] &&\n    res.data.length > 0\n  ) {\n    switch (sessionContext.httpRequest.headers[\"content-encoding\"]) {\n      case \"gzip\":\n        res.headers[\"Content-Encoding\"] = \"gzip\";\n        data = await gzipPromisified(data);\n        break;\n      case \"deflate\":\n        res.headers[\"Content-Encoding\"] = \"deflate\";\n        data = await deflatePromisified(data);\n    }\n  }\n\n  const httpResponse = sessionContext.httpResponse;\n  // Don't use httpResponse.socket as it may be null, even before end() is called\n  const connection = sessionContext.httpRequest.socket;\n\n  httpResponse.setHeader(\"Content-Length\", Buffer.byteLength(data));\n  httpResponse.writeHead(res.code, res.headers);\n  if (sessionContext.debug)\n    debug.outgoingHttpResponse(httpResponse, sessionContext.deviceId, res.data);\n  httpResponse.end(data);\n\n  if (connection.destroyed) {\n    logger.accessError({\n      sessionContext: sessionContext,\n      message: \"Connection dropped\",\n    });\n    await endSession(sessionContext);\n  } else if (close) {\n    session.clearProvisions(sessionContext);\n    await endSession(sessionContext);\n  } else {\n    const now = Date.now();\n    sessionContext.lastActivity = now;\n    currentSessions.set(connection, sessionContext);\n    if (now >= sessionContext.extendLock) {\n      sessionContext.extendLock = now + LOCK_REFRESH_INTERVAL;\n      const lockToken = await lock.acquireLock(\n        `cwmp_session_${sessionContext.deviceId}`,\n        sessionContext.timeout * 1000 + LOCK_REFRESH_INTERVAL + REQUEST_TIMEOUT,\n        0,\n        `cwmp_session_${sessionContext.sessionId}`,\n      );\n      if (!lockToken) throw new Error(\"Failed to extend lock\");\n    }\n  }\n}\n\nfunction recordFault(\n  sessionContext: SessionContext,\n  fault: Fault,\n  provisions,\n  channels,\n): void;\nfunction recordFault(sessionContext: SessionContext, fault: Fault): void;\nfunction recordFault(\n  sessionContext: SessionContext,\n  fault: Fault,\n  provisions?,\n  channels?,\n): void {\n  if (!provisions) {\n    provisions = sessionContext.provisions;\n    channels = sessionContext.channels;\n  }\n\n  const channelKeys = Object.keys(channels);\n  if (!channelKeys.length)\n    throw new Error(\"Fault not associated with a channel!\");\n\n  const faults = sessionContext.faults;\n  for (const channel of channelKeys) {\n    const provs = sessionContext.faults[channel]\n      ? sessionContext.faults[channel].provisions\n      : [];\n    faults[channel] = Object.assign(\n      { provisions: provs, timestamp: sessionContext.timestamp },\n      fault,\n    ) as SessionFault;\n    if (channel.startsWith(\"task_\")) {\n      const taskId = channel.slice(5);\n      for (const t of sessionContext.tasks)\n        if (t._id === taskId && t.expiry) faults[channel].expiry = t.expiry;\n    }\n\n    if (sessionContext.retries[channel] != null) {\n      ++sessionContext.retries[channel];\n    } else {\n      sessionContext.retries[channel] = 0;\n      if (channelKeys.length !== 1) faults[channel].retryNow = true;\n    }\n\n    if (channels[channel] === 0) faults[channel].precondition = true;\n\n    if (!sessionContext.faultsTouched) sessionContext.faultsTouched = {};\n    sessionContext.faultsTouched[channel] = true;\n\n    logger.accessWarn({\n      sessionContext: sessionContext,\n      message: \"Channel has faulted\",\n      fault: fault,\n      channel: channel,\n      retries: sessionContext.retries[channel],\n    });\n  }\n\n  for (let i = 0; i < provisions.length; ++i) {\n    for (const channel of channelKeys) {\n      if ((channels[channel] >> i) & 1)\n        faults[channel].provisions.push(provisions[i]);\n    }\n  }\n\n  for (const channel of channelKeys) {\n    const provs = faults[channel].provisions;\n    faults[channel].provisions = [];\n    appendProvisions(faults[channel].provisions, provs);\n  }\n\n  session.clearProvisions(sessionContext);\n}\n\nasync function inform(\n  sessionContext: SessionContext,\n  rpc: SoapMessage,\n): Promise<{ code: number; headers: Record<string, string>; data: string }> {\n  const acsResponse = await session.inform(\n    sessionContext,\n    rpc.cpeRequest as InformRequest,\n  );\n\n  const res = soap.response({\n    id: rpc.id,\n    acsResponse: acsResponse,\n    cwmpVersion: sessionContext.cwmpVersion,\n  });\n\n  const cookiesPath = localCache.getConfig(\n    sessionContext.cacheSnapshot,\n    \"cwmp.cookiesPath\",\n    \"\",\n    (e) => session.configContextCallback(sessionContext, e),\n  );\n\n  if (cookiesPath) {\n    res.headers[\"Set-Cookie\"] =\n      `session=${sessionContext.sessionId}; Path=${cookiesPath}`;\n  } else {\n    res.headers[\"Set-Cookie\"] = `session=${sessionContext.sessionId}`;\n  }\n\n  return res;\n}\n\nasync function transferComplete(sessionContext, rpc): Promise<void> {\n  const { acsResponse, operation, fault } = await session.transferComplete(\n    sessionContext,\n    rpc.cpeRequest,\n  );\n\n  if (!operation) {\n    logger.accessWarn({\n      sessionContext: sessionContext,\n      message: \"Unrecognized command key\",\n      rpc: rpc,\n    });\n  }\n\n  if (fault) {\n    Object.assign(sessionContext.retries, operation.retries);\n    recordFault(\n      sessionContext,\n      fault,\n      operation.provisions,\n      operation.channels,\n    );\n  }\n\n  const res = soap.response({\n    id: rpc.id,\n    acsResponse: acsResponse,\n    cwmpVersion: sessionContext.cwmpVersion,\n  });\n\n  return writeResponse(sessionContext, res);\n}\n\n// Append provisions and remove duplicates\nfunction appendProvisions(original, toAppend): boolean {\n  let modified = false;\n  const stringified = new WeakMap();\n\n  for (const p of original) stringified.set(p, JSON.stringify(p));\n\n  for (let i = toAppend.length - 1; i >= 0; --i) {\n    let p = toAppend[i];\n    const s = JSON.stringify(p);\n    for (let j = original.length - 1; j >= 0; --j) {\n      const ss = stringified.get(original[j]);\n      if (s === ss) {\n        if (!p || j >= original.length - (toAppend.length - i)) {\n          p = null;\n        } else {\n          original.splice(j, 1);\n          modified = true;\n        }\n      }\n    }\n\n    if (p) {\n      original.splice(original.length - (toAppend.length - i) + 1, 0, p);\n      stringified.set(p, s);\n      modified = true;\n    }\n  }\n\n  return modified;\n}\n\nasync function applyPresets(sessionContext: SessionContext): Promise<void> {\n  const deviceData = sessionContext.deviceData;\n  const presets = localCache.getPresets(sessionContext.cacheSnapshot);\n\n  // Filter presets based on existing faults\n  const blackList = {};\n  let whiteList = null;\n  let whiteListProvisions = null;\n  const RETRY_DELAY = +localCache.getConfig(\n    sessionContext.cacheSnapshot,\n    \"cwmp.retryDelay\",\n    300,\n    (e) => session.configContextCallback(sessionContext, e),\n  );\n\n  if (sessionContext.faults) {\n    for (const [channel, fault] of Object.entries(sessionContext.faults)) {\n      let retryTimestamp = 0;\n      if (!fault.retryNow) {\n        retryTimestamp =\n          fault.timestamp +\n          RETRY_DELAY * Math.pow(2, sessionContext.retries[channel]) * 1000;\n      }\n\n      if (retryTimestamp <= sessionContext.timestamp) {\n        whiteList = channel;\n        whiteListProvisions = fault.provisions;\n        break;\n      }\n\n      blackList[channel] = fault.precondition ? 1 : 2;\n    }\n  }\n\n  deviceData.timestamps.revision = 1;\n  deviceData.attributes.revision = 1;\n\n  const deviceEvents = {};\n  for (const p of deviceData.paths.find(Path.parse(\"Events\"), 0b001, 0b1)) {\n    const attrs = deviceData.attributes.get(p);\n    const t = attrs?.value[1][0] as number;\n    if (t >= sessionContext.timestamp)\n      deviceEvents[p.segments[1] as string] = true;\n  }\n\n  const parameters = new Set<string>();\n  const filteredPresets: Preset[] = [];\n\n  for (const preset of presets) {\n    if (whiteList != null) {\n      if (preset.channel !== whiteList) continue;\n    } else if (blackList[preset.channel] === 1) {\n      continue;\n    }\n\n    let eventsMatch = true;\n    for (const [k, v] of Object.entries(preset.events)) {\n      if (!v !== !deviceEvents[k.replace(/\\s+/g, \"_\")]) {\n        eventsMatch = false;\n        break;\n      }\n    }\n\n    if (!eventsMatch) continue;\n\n    if (preset.schedule?.schedule) {\n      const r = scheduling.cron(\n        sessionContext.timestamp,\n        preset.schedule.schedule,\n      );\n      if (!(r[0] + preset.schedule.duration > sessionContext.timestamp))\n        continue;\n    }\n\n    const pre = { ...preset };\n    const evalCallback = (e: Expression): Expression => {\n      if (e instanceof Expression.FunctionCall && e.name === \"NOW\")\n        return new Expression.Literal(sessionContext.timestamp);\n      if (e instanceof Expression.Parameter) {\n        // Mark channel in case of fault during fetching precondition\n        sessionContext.channels[preset.channel] = 0;\n        parameters.add(e.path.toString());\n      }\n      return e;\n    };\n\n    pre.precondition = preset.precondition.evaluate(evalCallback);\n\n    pre.provisions = pre.provisions.map((prov) => {\n      let args = prov.slice(1) as Expression[];\n      args = args.map((arg) => arg.evaluate(evalCallback));\n      return [prov[0], ...args];\n    });\n\n    filteredPresets.push(pre);\n  }\n\n  const declarations = [...parameters].map((v) => ({\n    path: Path.parse(v),\n    pathGet: 1,\n    pathSet: null,\n    attrGet: { value: 1 },\n    attrSet: null,\n    defer: true,\n  }));\n\n  const {\n    fault: flt,\n    rpcId: reqId,\n    rpc: acsReq,\n  } = await session.rpcRequest(sessionContext, declarations);\n\n  if (flt) {\n    recordFault(sessionContext, flt);\n    session.clearProvisions(sessionContext);\n    return applyPresets(sessionContext);\n  }\n\n  if (acsReq) return sendAcsRequest(sessionContext, reqId, acsReq);\n\n  session.clearProvisions(sessionContext);\n\n  if (whiteList != null)\n    session.addProvisions(sessionContext, whiteList, whiteListProvisions);\n\n  const appendProvisionsToFaults = {};\n\n  const evalCallback2 = (e: Expression): Expression.Literal => {\n    e = session.configContextCallback(sessionContext, e);\n    if (!(e instanceof Expression.Literal)) return new Expression.Literal(null);\n    return e;\n  };\n\n  for (const p of filteredPresets) {\n    if (p.precondition.evaluate(evalCallback2).value) {\n      const provs = p.provisions.map((pp) => [\n        pp[0],\n        ...pp\n          .slice(1)\n          .map((arg) => (arg as Expression).evaluate(evalCallback2).value),\n      ]) as [string, ...Value[]][];\n      if (blackList[p.channel] === 2) {\n        appendProvisionsToFaults[p.channel] = (\n          appendProvisionsToFaults[p.channel] || []\n        ).concat(provs);\n      } else {\n        session.addProvisions(sessionContext, p.channel, provs);\n      }\n    }\n  }\n\n  for (const [channel, provisions] of Object.entries(\n    appendProvisionsToFaults,\n  )) {\n    if (\n      appendProvisions(sessionContext.faults[channel].provisions, provisions)\n    ) {\n      if (!sessionContext.faultsTouched) sessionContext.faultsTouched = {};\n      sessionContext.faultsTouched[channel] = true;\n    }\n  }\n\n  // Don't increment when processing a single channel (e.g. after fault)\n  if (whiteList == null)\n    sessionContext.presetCycles = (sessionContext.presetCycles || 0) + 1;\n\n  if (sessionContext.presetCycles > MAX_CYCLES) {\n    const fault = {\n      code: \"preset_loop\",\n      message: \"The presets are stuck in an endless configuration loop\",\n      timestamp: sessionContext.timestamp,\n    };\n    recordFault(sessionContext, fault);\n    // No need to save retryNow\n    for (const f of Object.values(sessionContext.faults)) delete f.retryNow;\n    session.clearProvisions(sessionContext);\n    return sendAcsRequest(sessionContext);\n  }\n\n  deviceData.timestamps.dirty = 0;\n  deviceData.attributes.dirty = 0;\n  const {\n    fault: fault,\n    rpcId: id,\n    rpc: acsRequest,\n  } = await session.rpcRequest(sessionContext, null);\n\n  if (fault) {\n    recordFault(sessionContext, fault);\n    session.clearProvisions(sessionContext);\n    return applyPresets(sessionContext);\n  }\n\n  if (!acsRequest) {\n    for (const channel of Object.keys(sessionContext.channels)) {\n      if (sessionContext.faults[channel]) {\n        delete sessionContext.faults[channel];\n        if (!sessionContext.faultsTouched) sessionContext.faultsTouched = {};\n        sessionContext.faultsTouched[channel] = true;\n      }\n    }\n\n    if (whiteList != null) return applyPresets(sessionContext);\n\n    if (\n      sessionContext.deviceData.timestamps.dirty > 1 ||\n      sessionContext.deviceData.attributes.dirty > 1\n    )\n      return applyPresets(sessionContext);\n  }\n\n  return sendAcsRequest(sessionContext, id, acsRequest);\n}\n\nasync function nextRpc(sessionContext: SessionContext): Promise<void> {\n  const {\n    fault: fault,\n    rpcId: id,\n    rpc: acsRequest,\n  } = await session.rpcRequest(sessionContext, null);\n\n  if (fault) {\n    recordFault(sessionContext, fault);\n    session.clearProvisions(sessionContext);\n    return nextRpc(sessionContext);\n  }\n\n  if (acsRequest) return sendAcsRequest(sessionContext, id, acsRequest);\n\n  for (const [channel, flags] of Object.entries(sessionContext.channels)) {\n    if (flags && sessionContext.faults[channel]) {\n      delete sessionContext.faults[channel];\n      if (!sessionContext.faultsTouched) sessionContext.faultsTouched = {};\n\n      sessionContext.faultsTouched[channel] = true;\n    }\n    if (channel.startsWith(\"task_\")) {\n      const taskId = channel.slice(5);\n      if (!sessionContext.doneTasks) sessionContext.doneTasks = [];\n      sessionContext.doneTasks.push(taskId);\n\n      for (let j = 0; j < sessionContext.tasks.length; ++j) {\n        if (sessionContext.tasks[j]._id === taskId) {\n          sessionContext.tasks.splice(j, 1);\n          break;\n        }\n      }\n    }\n  }\n\n  session.clearProvisions(sessionContext);\n\n  // Clear expired tasks\n  sessionContext.tasks = sessionContext.tasks.filter((task) => {\n    if (!(task.expiry <= sessionContext.timestamp)) return true;\n\n    logger.accessInfo({\n      sessionContext: sessionContext,\n      message: \"Task expired\",\n      task: task,\n    });\n\n    if (!sessionContext.doneTasks) sessionContext.doneTasks = [];\n    sessionContext.doneTasks.push(task._id);\n\n    const channel = `task_${task._id}`;\n    if (sessionContext.faults[channel]) {\n      delete sessionContext.faults[channel];\n      if (!sessionContext.faultsTouched) sessionContext.faultsTouched = {};\n      sessionContext.faultsTouched[channel] = true;\n    }\n\n    return false;\n  });\n\n  const task = sessionContext.tasks.find(\n    (t) => !sessionContext.faults[`task_${t._id}`],\n  );\n\n  if (!task) return applyPresets(sessionContext);\n\n  let alias;\n\n  switch (task.name) {\n    case \"getParameterValues\":\n      // Set channel in case params array is empty\n      sessionContext.channels[`task_${task._id}`] = 0;\n      for (const p of task.parameterNames) {\n        session.addProvisions(sessionContext, `task_${task._id}`, [\n          [\"refresh\", p],\n        ]);\n      }\n\n      break;\n    case \"setParameterValues\":\n      // Set channel in case params array is empty\n      sessionContext.channels[`task_${task._id}`] = 0;\n      for (const p of task.parameterValues) {\n        session.addProvisions(sessionContext, `task_${task._id}`, [\n          [\"value\", p[0], p[1]],\n        ]);\n      }\n\n      break;\n    case \"refreshObject\":\n      session.addProvisions(sessionContext, `task_${task._id}`, [\n        [\"refresh\", task.objectName],\n      ]);\n      break;\n    case \"reboot\":\n      session.addProvisions(sessionContext, `task_${task._id}`, [[\"reboot\"]]);\n      break;\n    case \"factoryReset\":\n      session.addProvisions(sessionContext, `task_${task._id}`, [[\"reset\"]]);\n      break;\n    case \"download\":\n      session.addProvisions(sessionContext, `task_${task._id}`, [\n        [\"download\", task.fileType, task.fileName, task.targetFileName || \"\"],\n      ]);\n      break;\n    case \"addObject\":\n      alias = (task.parameterValues || [])\n        .map((p) => `${p[0]}:${JSON.stringify(p[1])}`)\n        .join(\",\");\n      session.addProvisions(sessionContext, `task_${task._id}`, [\n        [\"instances\", `${task.objectName}.[${alias}]`, \"+1\"],\n      ]);\n      break;\n    case \"deleteObject\":\n      session.addProvisions(sessionContext, `task_${task._id}`, [\n        [\"instances\", task.objectName, 0],\n      ]);\n      break;\n    case \"provisions\":\n      session.addProvisions(\n        sessionContext,\n        `task_${task._id}`,\n        task.provisions,\n      );\n      break;\n    default:\n      if (!sessionContext.doneTasks) sessionContext.doneTasks = [];\n      sessionContext.doneTasks.push(task._id);\n      sessionContext.tasks = sessionContext.tasks.filter((t) => t !== task);\n\n      logger.accessWarn({\n        sessionContext: sessionContext,\n        message: \"Invalid task\",\n        taskId: task._id,\n      });\n  }\n\n  return nextRpc(sessionContext);\n}\n\nasync function endSession(sessionContext: SessionContext): Promise<void> {\n  if (sessionContext.provisions.length) {\n    const fault = {\n      code: \"session_terminated\",\n      message: \"The TR-069 session was unsuccessfully terminated\",\n      timestamp: sessionContext.timestamp,\n    };\n    recordFault(sessionContext, fault);\n    // No need to save retryNow\n    for (const f of Object.values(sessionContext.faults)) delete f.retryNow;\n  }\n\n  const promises = [];\n\n  promises.push(\n    saveDevice(\n      sessionContext.deviceId,\n      sessionContext.deviceData,\n      sessionContext.new,\n      sessionContext.timestamp,\n    ),\n  );\n\n  if (sessionContext.operationsTouched) {\n    for (const k of Object.keys(sessionContext.operationsTouched)) {\n      if (sessionContext.operations[k]) {\n        promises.push(\n          saveOperation(\n            sessionContext.deviceId,\n            k,\n            sessionContext.operations[k],\n          ),\n        );\n      } else {\n        promises.push(deleteOperation(sessionContext.deviceId, k));\n      }\n    }\n  }\n\n  if (sessionContext.doneTasks?.length) {\n    promises.push(\n      clearTasks(sessionContext.deviceId, sessionContext.doneTasks),\n    );\n  }\n\n  if (sessionContext.faultsTouched) {\n    for (const k of Object.keys(sessionContext.faultsTouched)) {\n      if (sessionContext.faults[k]) {\n        sessionContext.faults[k].retries = sessionContext.retries[k];\n        promises.push(\n          saveFault(sessionContext.deviceId, k, sessionContext.faults[k]),\n        );\n      } else {\n        promises.push(deleteFault(sessionContext.deviceId, k));\n      }\n    }\n  }\n\n  await Promise.all(promises);\n  await lock.releaseLock(\n    `cwmp_session_${sessionContext.deviceId}`,\n    `cwmp_session_${sessionContext.sessionId}`,\n  );\n  if (sessionContext.new) {\n    logger.accessInfo({\n      sessionContext: sessionContext,\n      message: \"New device registered\",\n    });\n  }\n}\n\nasync function sendAcsRequest(\n  sessionContext: SessionContext,\n  id?: string,\n  acsRequest?: AcsRequest,\n): Promise<void> {\n  if (!acsRequest)\n    return writeResponse(sessionContext, soap.response(null), true);\n\n  if (acsRequest.name === \"Download\") {\n    acsRequest.fileSize = 0;\n    if (!acsRequest.url) {\n      let prefix = \"\" + config.get(\"FS_URL_PREFIX\");\n\n      if (!prefix) {\n        const FS_PORT = +config.get(\"FS_PORT\");\n        const ssl = !!config.get(\"FS_SSL_CERT\");\n        const origin = getRequestOrigin(sessionContext.httpRequest);\n        let hostname = origin.localAddress;\n        if (origin.host) [hostname] = origin.host.split(\":\", 1);\n        prefix = (ssl ? \"https\" : \"http\") + `://${hostname}:${FS_PORT}/`;\n      }\n\n      acsRequest.url = prefix + encodeURI(acsRequest.fileName);\n\n      const files = localCache.getFiles(sessionContext.cacheSnapshot);\n      if (files[acsRequest.fileName])\n        acsRequest.fileSize = files[acsRequest.fileName].length;\n    }\n  }\n\n  const rpc = {\n    id: id,\n    acsRequest: acsRequest,\n    cwmpVersion: sessionContext.cwmpVersion,\n  };\n\n  logger.accessInfo({\n    sessionContext: sessionContext,\n    message: \"ACS request\",\n    rpc: rpc,\n  });\n\n  const res = soap.response(rpc);\n  return writeResponse(sessionContext, res);\n}\n\n// When socket closes, store active sessions in cache\nexport async function onConnection(socket: Socket): Promise<void> {\n  try {\n    await once(socket, \"close\", MAX_SESSION_DURATION);\n  } catch {\n    socket.destroy();\n  }\n\n  const sessionContext = currentSessions.get(socket);\n  if (!sessionContext) return;\n  currentSessions.delete(socket);\n  if (sessionContext.authState !== 2) {\n    logger.accessError({\n      message: \"Authentication failure\",\n      sessionContext: sessionContext,\n    });\n    return;\n  }\n\n  const now = Date.now();\n\n  const lastActivity = sessionContext.lastActivity;\n  const timeoutMsg = {\n    sessionContext: sessionContext,\n    message: \"Session timeout\",\n    sessionTimestamp: sessionContext.timestamp,\n  };\n\n  const timeout =\n    sessionContext.lastActivity + sessionContext.timeout * 1000 - now;\n\n  if (timeout <= 0) {\n    logger.accessError(timeoutMsg);\n    // TODO it's possible that lock would have already been expired\n    await endSession(sessionContext);\n    return;\n  }\n\n  await cache.set(\n    `session_${sessionContext.sessionId}`,\n    await session.serialize(sessionContext),\n    Math.ceil(timeout / 1000) + 3,\n  );\n\n  await setTimeoutPromise(timeout + 1000, false);\n  const sessionStr = await cache.get(`session_${sessionContext.sessionId}`);\n  if (!sessionStr) return;\n\n  const _sessionContext = await session.deserialize(sessionStr);\n  if (_sessionContext.lastActivity === lastActivity) {\n    logger.accessError(timeoutMsg);\n    await endSession(sessionContext);\n  }\n}\n\nexport async function onClientError(err: Error, socket: Socket): Promise<void> {\n  const remoteAddress = getSocketEndpoints(socket).remoteAddress;\n  const cacheSnapshot = await localCache.getRevision();\n  const debugEnabled = localCache.getConfig(\n    cacheSnapshot,\n    \"cwmp.debug\",\n    false,\n    (e) => {\n      if (e instanceof Expression.FunctionCall) {\n        if (e.name === \"REMOTE_ADDRESS\")\n          return new Expression.Literal(remoteAddress);\n        if (e.name === \"NOW\") return new Expression.Literal(Date.now());\n      }\n      if (!(e instanceof Expression.Literal))\n        return new Expression.Literal(null);\n      return e;\n    },\n  );\n\n  if (debugEnabled) debug.clientError(remoteAddress, err);\n}\n\nsetInterval(() => {\n  if (stats.droppedRequests) {\n    logger.warn({\n      message: \"Worker overloaded\",\n      droppedRequests: stats.droppedRequests,\n      totalRequests: stats.totalRequests,\n      initiatedSessions: stats.initiatedSessions,\n      pid: process.pid,\n    });\n  }\n\n  stats.totalRequests = 0;\n  stats.droppedRequests = 0;\n  stats.initiatedSessions = 0;\n}, 10000).unref();\n\nasync function reportBadState(sessionContext: SessionContext): Promise<void> {\n  logger.accessError({\n    message: \"Bad session state\",\n    sessionContext: sessionContext,\n  });\n  const httpResponse = sessionContext.httpResponse;\n  const body = \"Bad session state\";\n  httpResponse.setHeader(\"Content-Length\", Buffer.byteLength(body));\n  httpResponse.writeHead(400, { Connection: \"close\" });\n  if (sessionContext.debug)\n    debug.outgoingHttpResponse(httpResponse, sessionContext.deviceId, body);\n  httpResponse.end(body);\n  if (sessionContext.state) return endSession(sessionContext);\n}\n\nasync function responseUnauthorized(\n  sessionContext: SessionContext,\n  close: boolean,\n): Promise<void> {\n  const resHeaders = {};\n  if (close) {\n    // Invalid credentials\n    logger.accessError({\n      message: \"Authentication failure\",\n      sessionContext: sessionContext,\n    });\n\n    resHeaders[\"Connection\"] = \"close\";\n  } else {\n    if (getRequestOrigin(sessionContext.httpRequest).encrypted) {\n      resHeaders[\"WWW-Authenticate\"] = `Basic realm=\"${REALM}\"`;\n    } else {\n      const nonce = crypto.randomBytes(16).toString(\"hex\");\n      sessionsNonces.set(sessionContext.httpRequest.socket, nonce);\n      let d = `Digest realm=\"${REALM}\"`;\n      d += ',qop=\"auth,auth-int\"';\n      d += `,nonce=\"${nonce}\"`;\n\n      resHeaders[\"WWW-Authenticate\"] = d;\n    }\n    currentSessions.set(sessionContext.httpRequest.socket, sessionContext);\n  }\n\n  const httpResponse = sessionContext.httpResponse;\n  const body = \"Unauthorized\";\n  httpResponse.setHeader(\"Content-Length\", Buffer.byteLength(body));\n  httpResponse.writeHead(401, resHeaders);\n  if (sessionContext.debug)\n    debug.outgoingHttpResponse(httpResponse, sessionContext.deviceId, body);\n  httpResponse.end(body);\n}\n\nasync function processRequest(\n  sessionContext: SessionContext,\n  rpc: SoapMessage,\n  parseWarnings: Record<string, unknown>[],\n  body: string,\n): Promise<void> {\n  for (const w of parseWarnings) {\n    w.sessionContext = sessionContext;\n    logger.accessWarn(w);\n  }\n\n  if (sessionContext.state === 0) {\n    if (rpc.cpeRequest?.name !== \"Inform\")\n      return reportBadState(sessionContext);\n\n    const res = await inform(sessionContext, rpc);\n\n    sessionContext.debug = !!localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.debug\",\n      false,\n      (e) => session.configContextCallback(sessionContext, e),\n    );\n\n    if (!sessionContext.timeout) {\n      sessionContext.timeout = +localCache.getConfig(\n        sessionContext.cacheSnapshot,\n        \"cwmp.sessionTimeout\",\n        30,\n        (e) => session.configContextCallback(sessionContext, e),\n      );\n    }\n\n    sessionContext.httpRequest.socket.setTimeout(sessionContext.timeout * 1000);\n\n    if (sessionContext.debug) {\n      debug.incomingHttpRequest(\n        sessionContext.httpRequest,\n        sessionContext.deviceId,\n        body,\n      );\n    }\n\n    const authenticated = await authenticate(sessionContext, body);\n    if (!authenticated) {\n      if (!sessionContext.authState) {\n        sessionContext.authState = 1;\n        return responseUnauthorized(sessionContext, false);\n      } else {\n        return responseUnauthorized(sessionContext, true);\n      }\n    }\n\n    sessionContext.extendLock =\n      sessionContext.timestamp + LOCK_REFRESH_INTERVAL;\n    const lockToken = await lock.acquireLock(\n      `cwmp_session_${sessionContext.deviceId}`,\n      sessionContext.timeout * 1000 + LOCK_REFRESH_INTERVAL + REQUEST_TIMEOUT,\n      0,\n      `cwmp_session_${sessionContext.sessionId}`,\n    );\n\n    if (!lockToken) {\n      logger.accessError({\n        message: \"CPE already in session\",\n        sessionContext: sessionContext,\n      });\n\n      const _body = \"CPE already in session\";\n      sessionContext.httpResponse.setHeader(\n        \"Content-Length\",\n        Buffer.byteLength(_body),\n      );\n      sessionContext.httpResponse.writeHead(400, { Connection: \"close\" });\n      if (sessionContext.debug) {\n        debug.outgoingHttpResponse(\n          sessionContext.httpResponse,\n          sessionContext.deviceId,\n          _body,\n        );\n      }\n      sessionContext.httpResponse.end(_body);\n      return;\n    }\n\n    sessionContext.state = 1;\n    sessionContext.authState = 2;\n\n    logger.accessInfo({\n      sessionContext: sessionContext,\n      message: \"Inform\",\n      rpc: rpc,\n    });\n\n    return writeResponse(sessionContext, res);\n  }\n\n  if (sessionContext.debug) {\n    debug.incomingHttpRequest(\n      sessionContext.httpRequest,\n      sessionContext.deviceId,\n      body,\n    );\n  }\n\n  // Reauthenticate in case of new connection\n  if (sessionContext.authState !== 2) {\n    const authenticated = await authenticate(sessionContext, body);\n    if (!authenticated) {\n      if (!sessionContext.authState) {\n        sessionContext.authState = 1;\n        return responseUnauthorized(sessionContext, false);\n      } else {\n        await endSession(sessionContext);\n        return responseUnauthorized(sessionContext, true);\n      }\n    }\n    sessionContext.authState = 2;\n  }\n\n  if (rpc.cpeRequest) {\n    if (rpc.cpeRequest.name === \"TransferComplete\") {\n      if (sessionContext.state !== 1) return reportBadState(sessionContext);\n\n      logger.accessInfo({\n        sessionContext: sessionContext,\n        message: \"CPE request\",\n        rpc: rpc,\n      });\n      return transferComplete(sessionContext, rpc);\n    } else if (rpc.cpeRequest.name === \"GetRPCMethods\") {\n      if (sessionContext.state !== 1) return reportBadState(sessionContext);\n\n      logger.accessInfo({\n        sessionContext: sessionContext,\n        message: \"CPE request\",\n        rpc: rpc,\n      });\n      const res = soap.response({\n        id: rpc.id,\n        acsResponse: {\n          name: \"GetRPCMethodsResponse\",\n          methodList: [\"Inform\", \"GetRPCMethods\", \"TransferComplete\"],\n        } as GetRPCMethodsResponse,\n        cwmpVersion: sessionContext.cwmpVersion,\n      });\n      return writeResponse(sessionContext, res);\n    } else {\n      if (sessionContext.state !== 1 || rpc.cpeRequest.name === \"Inform\")\n        return void reportBadState(sessionContext);\n\n      throw new Error(\"ACS method not supported\");\n    }\n  } else if (rpc.cpeResponse) {\n    if (sessionContext.state !== 2) return reportBadState(sessionContext);\n\n    const fault = await session.rpcResponse(\n      sessionContext,\n      rpc.id,\n      rpc.cpeResponse,\n    );\n    if (fault) {\n      recordFault(sessionContext, fault);\n      session.clearProvisions(sessionContext);\n    }\n    return nextRpc(sessionContext);\n  } else if (rpc.cpeFault) {\n    if (sessionContext.state !== 2) return reportBadState(sessionContext);\n\n    logger.accessWarn({\n      sessionContext: sessionContext,\n      message: \"CPE fault\",\n      rpc: rpc,\n    });\n\n    const fault = await session.rpcFault(sessionContext, rpc.id, rpc.cpeFault);\n    if (fault) {\n      recordFault(sessionContext, fault);\n      session.clearProvisions(sessionContext);\n    }\n    return nextRpc(sessionContext);\n  } else if (rpc.unknownMethod) {\n    if (sessionContext.state === 1) {\n      logger.accessWarn({\n        sessionContext: sessionContext,\n        message: \"Method not supported\",\n        method: rpc.unknownMethod,\n      });\n\n      const f: CpeFault = {\n        faultCode: \"Server\",\n        faultString: \"CWMP fault\",\n        detail: {\n          faultCode: \"8000\",\n          faultString: \"Method not supported\",\n        },\n      };\n\n      const res = soap.response({\n        id: rpc.id,\n        acsFault: f,\n        cwmpVersion: sessionContext.cwmpVersion,\n      });\n\n      return writeResponse(sessionContext, res);\n    } else if (sessionContext.state === 2) {\n      const fault = {\n        code: \"invalid_response\",\n        message: \"Response name does not match request name\",\n      };\n      recordFault(sessionContext, fault);\n      session.clearProvisions(sessionContext);\n      return nextRpc(sessionContext);\n    } else {\n      return reportBadState(sessionContext);\n    }\n  } else {\n    // CPE sent empty response\n    if (sessionContext.state !== 1) return reportBadState(sessionContext);\n\n    sessionContext.state = 2;\n    const { faults, operations } =\n      await session.timeoutOperations(sessionContext);\n\n    for (const [i, f] of faults.entries()) {\n      for (const [k, v] of Object.entries(operations[i].retries))\n        sessionContext.retries[k] = v;\n\n      recordFault(\n        sessionContext,\n        f,\n        operations[i].provisions,\n        operations[i].channels,\n      );\n    }\n\n    return nextRpc(sessionContext);\n  }\n}\n\nexport async function listener(\n  httpRequest: IncomingMessage,\n  httpResponse: ServerResponse,\n): Promise<void> {\n  stats.concurrentRequests += 1;\n  try {\n    await listenerAsync(httpRequest, httpResponse);\n  } catch (err) {\n    currentSessions.delete(httpRequest.socket);\n    throw err;\n  } finally {\n    stats.concurrentRequests -= 1;\n  }\n}\n\nasync function clientError(\n  httpRequest: IncomingMessage,\n  httpResponse: ServerResponse,\n  sessionContext: SessionContext,\n  body: string,\n  msg: string,\n): Promise<void> {\n  let debugEnabled: boolean;\n  let deviceId: string = null;\n\n  if (sessionContext) {\n    debugEnabled = sessionContext.debug;\n    deviceId = sessionContext.deviceId;\n  } else {\n    const cacheSnapshot = await localCache.getRevision();\n    const remoteAddress = getRequestOrigin(httpRequest).remoteAddress;\n    debugEnabled = localCache.getConfig(\n      cacheSnapshot,\n      \"cwmp.debug\",\n      false,\n      (e) => {\n        if (e instanceof Expression.FunctionCall) {\n          if (e.name === \"REMOTE_ADDRESS\")\n            return new Expression.Literal(remoteAddress);\n          if (e.name === \"NOW\") return new Expression.Literal(Date.now());\n        }\n        if (!(e instanceof Expression.Literal))\n          return new Expression.Literal(null);\n        return e;\n      },\n    );\n  }\n\n  httpResponse.setHeader(\"Content-Length\", Buffer.byteLength(msg));\n  httpResponse.writeHead(400, { Connection: \"close\" });\n\n  if (debugEnabled) {\n    debug.incomingHttpRequest(httpRequest, deviceId, body);\n    debug.outgoingHttpResponse(httpResponse, deviceId, msg);\n  }\n\n  httpResponse.end(msg);\n  if (sessionContext?.state) await endSession(sessionContext);\n}\n\nfunction decodeString(buffer: Buffer, charset: string): string {\n  try {\n    return buffer.toString(charset as BufferEncoding);\n  } catch {\n    if (encodingExists(charset)) return decode(buffer, charset);\n  }\n  return null;\n}\n\nasync function listenerAsync(\n  httpRequest: IncomingMessage,\n  httpResponse: ServerResponse,\n): Promise<void> {\n  stats.totalRequests += 1;\n\n  if (httpRequest.method !== \"POST\") {\n    httpResponse.writeHead(405, {\n      Allow: \"POST\",\n      Connection: \"close\",\n    });\n    httpResponse.end(\"405 Method Not Allowed\");\n    return;\n  }\n\n  let sessionId;\n  // Separation by comma is important as some devices don't comform to standard\n  const COOKIE_REGEX =\n    /\\s*([a-zA-Z0-9\\-_]+?)\\s*=\\s*\"?([a-zA-Z0-9\\-_]*?)\"?\\s*(,|;|$)/g;\n  let match;\n  while ((match = COOKIE_REGEX.exec(httpRequest.headers.cookie)))\n    if (match[1] === \"session\") sessionId = match[2];\n\n  // If overloaded, ask CPE to retry in 60 seconds\n  if (!sessionId && stats.concurrentRequests > MAX_CONCURRENT_REQUESTS) {\n    httpResponse.writeHead(503, {\n      \"Retry-after\": 60,\n      Connection: \"close\",\n    });\n    httpResponse.end(\"503 Service Unavailable\");\n    stats.droppedRequests += 1;\n    return;\n  }\n\n  let stream: Readable = httpRequest;\n  if (httpRequest.headers[\"content-encoding\"]) {\n    switch (httpRequest.headers[\"content-encoding\"]) {\n      case \"gzip\":\n        stream = pipeline(stream, zlib.createGunzip(), () => {\n          // Errors are also raised by the async iterator\n        });\n        break;\n      case \"deflate\":\n        stream = pipeline(stream, zlib.createInflate(), () => {\n          // Errors are also raised by the async iterator\n        });\n        break;\n      default:\n        httpResponse.writeHead(415, { Connection: \"close\" });\n        httpResponse.end(\"415 Unsupported Media Type\");\n        return;\n    }\n  }\n\n  const chunks: Buffer[] = [];\n  try {\n    let readableEnded = false;\n    stream.on(\"end\", () => {\n      readableEnded = true;\n    });\n    for await (const chunk of stream) chunks.push(chunk);\n    // In Node versions prior to 15, the stream will not emit an error if the\n    // connection is closed before the stream is finished.\n    // For Node 12.9+ we can just use stream.readableEnded\n    if (!readableEnded) throw new Error(\"Connection closed\");\n  } catch {\n    return;\n  }\n\n  const body = Buffer.concat(chunks);\n\n  let sessionContext = currentSessions.get(httpRequest.socket);\n\n  if (sessionContext) {\n    currentSessions.delete(httpRequest.socket);\n    sessionContext.httpRequest = httpRequest;\n    sessionContext.httpResponse = httpResponse;\n    if (\n      (sessionContext.sessionId !== sessionId && sessionContext.state) ||\n      sessionContext.lastActivity + sessionContext.timeout * 1000 < Date.now()\n    ) {\n      logger.accessError({\n        message: \"Invalid session\",\n        sessionContext: sessionContext,\n      });\n\n      return clientError(\n        httpRequest,\n        httpResponse,\n        sessionContext,\n        body.toString(),\n        \"Invalid session\",\n      );\n    }\n  }\n\n  let charset: string;\n  if (httpRequest.headers[\"content-type\"]) {\n    const m = httpRequest.headers[\"content-type\"].match(\n      /charset=['\"]?([^'\"\\s]+)/i,\n    );\n    if (m) charset = m[1].toLowerCase();\n  }\n\n  if (!charset) {\n    const parse = parseXmlDeclaration(body);\n    const e = parse ? parse.find((s) => s.localName === \"encoding\") : null;\n    charset = e ? e.value.toLowerCase() : \"utf8\";\n  }\n\n  const bodyStr = decodeString(body, charset);\n\n  if (bodyStr == null) {\n    if (!sessionContext && sessionId) {\n      await new Promise((resolve) => setTimeout(resolve, 100));\n      const sessionContextString = await cache.pop(`session_${sessionId}`);\n      if (sessionContextString) {\n        sessionContext = await session.deserialize(sessionContextString);\n        sessionContext.httpRequest = httpRequest;\n        sessionContext.httpResponse = httpResponse;\n      }\n    }\n\n    const msg = `Unknown encoding '${charset}'`;\n    logger.accessError({\n      message: \"XML parse error\",\n      parseError: msg,\n      sessionContext: sessionContext || {\n        httpRequest: httpRequest,\n        httpResponse: httpResponse,\n      },\n    });\n    return clientError(\n      httpRequest,\n      httpResponse,\n      sessionContext,\n      body.toString(),\n      msg,\n    );\n  }\n\n  const parseWarnings = [];\n  let rpc: SoapMessage;\n  try {\n    rpc = soap.request(bodyStr, parseWarnings);\n  } catch (err) {\n    if (!sessionContext && sessionId) {\n      await new Promise((resolve) => setTimeout(resolve, 100));\n      const sessionContextString = await cache.pop(`session_${sessionId}`);\n      if (sessionContextString) {\n        sessionContext = await session.deserialize(sessionContextString);\n        sessionContext.httpRequest = httpRequest;\n        sessionContext.httpResponse = httpResponse;\n      }\n    }\n\n    logger.accessError({\n      message: \"XML parse error\",\n      parseError: err.message,\n      sessionContext: sessionContext || {\n        httpRequest: httpRequest,\n        httpResponse: httpResponse,\n      },\n    });\n\n    return clientError(\n      httpRequest,\n      httpResponse,\n      sessionContext,\n      bodyStr,\n      err.message,\n    );\n  }\n\n  if (!sessionContext && sessionId && rpc.cpeRequest?.name !== \"Inform\") {\n    await new Promise((resolve) => setTimeout(resolve, 100));\n    const sessionContextString = await cache.pop(`session_${sessionId}`);\n    if (sessionContextString) {\n      sessionContext = await session.deserialize(sessionContextString);\n      sessionContext.httpRequest = httpRequest;\n      sessionContext.httpResponse = httpResponse;\n      httpRequest.socket.setTimeout(sessionContext.timeout * 1000);\n      if (sessionContext.authState !== 1) sessionContext.authState = 0;\n    }\n  }\n\n  if (sessionContext)\n    return processRequest(sessionContext, rpc, parseWarnings, bodyStr);\n\n  if (rpc.cpeRequest?.name !== \"Inform\") {\n    logger.accessError({\n      message: \"Invalid session\",\n      sessionContext: {\n        httpRequest: httpRequest,\n        httpResponse: httpResponse,\n      },\n    });\n\n    return clientError(\n      httpRequest,\n      httpResponse,\n      null,\n      bodyStr,\n      \"Invalid session\",\n    );\n  }\n\n  if (stats.concurrentRequests > MAX_CONCURRENT_REQUESTS) {\n    // Check again just in case device included old session ID\n    // from the previous session\n    httpResponse.writeHead(503, { \"Retry-after\": 60, Connection: \"close\" });\n    httpResponse.end(\"503 Service Unavailable\");\n    stats.droppedRequests += 1;\n    return;\n  }\n\n  stats.initiatedSessions += 1;\n  const deviceId = generateDeviceId(rpc.cpeRequest.deviceId);\n\n  const cacheSnapshot = await localCache.getRevision();\n\n  const _sessionContext = session.init(\n    deviceId,\n    rpc.cwmpVersion,\n    rpc.sessionTimeout,\n  );\n\n  _sessionContext.cacheSnapshot = cacheSnapshot;\n\n  _sessionContext.httpRequest = httpRequest;\n  _sessionContext.httpResponse = httpResponse;\n  _sessionContext.sessionId = crypto.randomBytes(8).toString(\"hex\");\n\n  const [dueTasks, faults, operations] = await Promise.all([\n    getDueTasks(deviceId, _sessionContext.timestamp),\n    getFaults(deviceId),\n    getOperations(deviceId),\n  ]);\n\n  _sessionContext.tasks = dueTasks[0];\n  _sessionContext.operations = operations;\n  _sessionContext.faults = faults;\n  _sessionContext.retries = {};\n  for (const [k, v] of Object.entries(_sessionContext.faults)) {\n    if (v.expiry >= _sessionContext.timestamp) {\n      // Delete expired faults\n      delete _sessionContext.faults[k];\n      if (!_sessionContext.faultsTouched) _sessionContext.faultsTouched = {};\n      _sessionContext.faultsTouched[k] = true;\n    } else {\n      _sessionContext.retries[k] = v.retries;\n    }\n  }\n\n  const parameters = await fetchDevice(\n    _sessionContext.deviceId,\n    _sessionContext.timestamp,\n  );\n\n  if (parameters) {\n    for (const p of parameters) {\n      const path = _sessionContext.deviceData.paths.add(p[0]);\n      _sessionContext.deviceData.timestamps.set(path, p[1], 0);\n      if (p[2]) _sessionContext.deviceData.attributes.set(path, p[2], 0);\n    }\n  } else {\n    // Device not available in database, mark as new\n    _sessionContext.new = true;\n  }\n\n  return processRequest(_sessionContext, rpc, parseWarnings, bodyStr);\n}\n"
  },
  {
    "path": "lib/db/db.ts",
    "content": "import { MongoClient, Collection, GridFSBucket } from \"mongodb\";\nimport { get } from \"../config.ts\";\nimport * as MongoTypes from \"./types.ts\";\n\nexport let filesBucket: GridFSBucket;\n\nexport const collections = {\n  devices: null as Collection<MongoTypes.Device>,\n  presets: null as Collection<MongoTypes.Preset>,\n  objects: null as Collection<MongoTypes.Object>,\n  provisions: null as Collection<MongoTypes.Provision>,\n  virtualParameters: null as Collection<MongoTypes.VirtualParameter>,\n  faults: null as Collection<MongoTypes.Fault>,\n  tasks: null as Collection<MongoTypes.Task>,\n  files: null as Collection<MongoTypes.File>,\n  operations: null as Collection<MongoTypes.Operation>,\n  permissions: null as Collection<MongoTypes.Permission>,\n  users: null as Collection<MongoTypes.User>,\n  config: null as Collection<MongoTypes.Config>,\n  cache: null as Collection<MongoTypes.Cache>,\n  locks: null as Collection<MongoTypes.Lock>,\n  views: null as Collection<MongoTypes.View>,\n};\n\nlet clientPromise: Promise<MongoClient>;\n\nexport async function connect(): Promise<void> {\n  clientPromise = MongoClient.connect(\"\" + get(\"MONGODB_CONNECTION_URL\"));\n\n  const client = await clientPromise;\n  const db = client.db();\n\n  collections.tasks = db.collection(\"tasks\");\n  collections.devices = db.collection(\"devices\");\n  collections.presets = db.collection(\"presets\");\n  collections.objects = db.collection(\"objects\");\n  collections.files = db.collection(\"fs.files\");\n  collections.provisions = db.collection(\"provisions\");\n  collections.virtualParameters = db.collection(\"virtualParameters\");\n  collections.faults = db.collection(\"faults\");\n  collections.operations = db.collection(\"operations\");\n  collections.permissions = db.collection(\"permissions\");\n  collections.users = db.collection(\"users\");\n  collections.config = db.collection(\"config\");\n  collections.cache = db.collection(\"cache\");\n  collections.locks = db.collection(\"locks\");\n  collections.views = db.collection(\"views\");\n  filesBucket = new GridFSBucket(db);\n\n  await Promise.all([\n    collections.tasks.createIndex({ device: 1, timestamp: 1 }),\n    collections.cache.createIndex({ expire: 1 }, { expireAfterSeconds: 0 }),\n    collections.locks.createIndex({ expire: 1 }, { expireAfterSeconds: 0 }),\n  ]);\n}\n\nexport async function disconnect(): Promise<void> {\n  if (clientPromise != null) await (await clientPromise).close();\n}\n"
  },
  {
    "path": "lib/db/synth.ts",
    "content": "import { Filter } from \"mongodb\";\nimport { EJSON } from \"bson\";\nimport { complement } from \"espresso-iisojs\";\nimport { parseLikePattern } from \"../common/expression/parser.ts\";\nimport Expression from \"../common/expression.ts\";\nimport { decodeTag } from \"../util.ts\";\nimport {\n  SynthContextBase,\n  likeDisjoint,\n  likeImplies,\n  Clause,\n} from \"../common/expression/synth.ts\";\nimport normalize from \"../common/expression/normalize.ts\";\n\ntype Minterm = number[];\n\nfunction getParam(exp: Expression, collection: string): string {\n  if (!(exp instanceof Expression.Parameter))\n    throw new Error(\"Left-hand operand must be a parameter\");\n\n  const p = exp.path.toString();\n  if (collection === \"devices\") {\n    if (p === \"DeviceID.ID\") return \"_id\";\n    else if (p === \"DeviceID\") return \"_deviceId\";\n    else if (p.startsWith(\"DeviceID.\")) return \"_deviceId._\" + p.slice(9);\n    else if (p === \"Events.Inform\") return \"_lastInform\";\n    else if (p === \"Events.Registered\") return \"_registered\";\n    else if (p === \"Events.0_BOOTSTRAP\") return \"_lastBootstrap\";\n    else if (p === \"Events.1_BOOT\") return \"_lastBoot\";\n    else if (!p.endsWith(\"._value\") && !p.startsWith(\"Tags.\"))\n      return `${p}._value`;\n  }\n\n  return p;\n}\n\nfunction getTypes(parameter: string, collection: string): string[] {\n  if (collection === \"devices\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"_lastInform\") return [\"date\"];\n    if (parameter === \"_registered\") return [\"date\"];\n    if (parameter === \"_lastBootstrap\") return [\"date\"];\n    if (parameter === \"_lastBoot\") return [\"date\"];\n    if (parameter.startsWith(\"_deviceId.\")) return [\"string\"];\n    if (parameter === \"Reboot._value\") return [\"date\"];\n    if (parameter === \"FactoryReset._value\") return [\"date\"];\n    if (parameter.endsWith(\"_timestamp\")) return [\"date\"];\n    if (parameter.startsWith(\"Downloads.\")) {\n      if (parameter.endsWith(\"Download._value\")) return [\"date\"];\n      if (parameter.endsWith(\"Time._value\")) return [\"date\"];\n      if (parameter.endsWith(\"Name._value\")) return [\"string\"];\n      if (parameter.endsWith(\"Type._value\")) return [\"string\"];\n    }\n    if (parameter.endsWith(\"_value\"))\n      return [\"bool\", \"number\", \"date\", \"string\"];\n  } else if (collection === \"tasks\") {\n    if (parameter === \"_id\") return [\"oid\"];\n    if (parameter === \"timestamp\") return [\"date\"];\n    if (parameter === \"expiry\") return [\"date\"];\n    if (parameter === \"name\") return [\"string\"];\n    if (parameter === \"device\") return [\"string\"];\n  } else if (collection === \"faults\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"timestamp\") return [\"date\"];\n    if (parameter === \"expiry\") return [\"date\"];\n    if (parameter === \"code\") return [\"string\"];\n    if (parameter === \"retries\") return [\"number\"];\n    if (parameter === \"channel\") return [\"string\"];\n    if (parameter === \"device\") return [\"string\"];\n    if (parameter === \"message\") return [\"string\"];\n  } else if (collection === \"users\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"roles\") return [\"string\"];\n    else if (parameter === \"password\")\n      throw new Error(\"Cannot query restricted parameters\");\n    else if (parameter === \"salt\")\n      throw new Error(\"Cannot query restricted parameters\");\n  } else if (collection === \"config\") {\n    if (parameter === \"_id\") return [\"string\"];\n    else if (parameter === \"value\") return [\"string\"];\n  } else if (collection === \"files\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"metadata.fileType\") return [\"string\"];\n    if (parameter === \"metadata.oui\") return [\"string\"];\n    if (parameter === \"metadata.productClass\") return [\"string\"];\n    if (parameter === \"metadata.version\") return [\"string\"];\n  } else if (collection === \"permissions\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"role\") return [\"string\"];\n    if (parameter === \"resource\") return [\"devices\"];\n    if (parameter === \"access\") return [\"number\"];\n    if (parameter === \"filter\") return [\"string\"];\n    if (parameter === \"validate\") return [\"string\"];\n  } else if (collection === \"presets\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"weight\") return [\"number\"];\n    if (parameter === \"channel\") return [\"string\"];\n    if (parameter === \"precondition\") return [\"string\"];\n    if (parameter.startsWith(\"events.\")) return [\"bool\"];\n  } else if (collection === \"provisions\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"script\") return [\"string\"];\n  } else if (collection === \"virtualParameters\") {\n    if (parameter === \"_id\") return [\"string\"];\n    if (parameter === \"script\") return [\"string\"];\n  }\n\n  if (parameter === \"_id\") return [\"oid\", \"string\"];\n  return [\"bool\", \"number\", \"date\", \"string\"];\n}\n\nfunction roundOid(oid: string, roundUp: boolean): string {\n  const match = (oid.match(/^[0-9a-f]*/)?.[0] ?? \"\").slice(0, 24);\n  let num = BigInt(\"0x0\" + match);\n  let lastChar = 0;\n  if (oid.length > match.length) lastChar += oid.charCodeAt(match.length);\n  if (match.length < 24) lastChar -= 48;\n  if (lastChar > 0 && roundUp) num++;\n  num <<= BigInt(4 * (24 - match.length));\n  if (lastChar < 0 && !roundUp) --num;\n  const str = num.toString(16);\n  if (str.length > 24 || str.startsWith(\"-\")) return null;\n  return str.padStart(24, \"0\");\n}\n\nfunction groupBy<T, K>(\n  input: T[],\n  callback: (item: T) => K,\n): Iterable<[K, T[]]> {\n  const groups = new Map<K, T[]>();\n  for (const item of input) {\n    const key = callback(item);\n    let arr = groups.get(key);\n    if (!arr) groups.set(key, (arr = []));\n    arr.push(item);\n  }\n\n  return groups.entries();\n}\n\nabstract class MongoClause {\n  abstract readonly parameter: string;\n  abstract toQuery(truthy: boolean): Filter<unknown>;\n  toString(): string {\n    return JSON.stringify(this.toQuery(true));\n  }\n}\n\nclass MongoClauseArray extends MongoClause {\n  constructor(\n    public readonly parameter: string,\n    public readonly value: string,\n  ) {\n    super();\n  }\n\n  toQuery(truthy: boolean): Filter<unknown> {\n    if (truthy) return { [this.parameter]: { $eq: this.value } };\n    else return { [this.parameter]: { $ne: this.value } };\n  }\n}\n\nclass MongoClauseCompare<T> extends MongoClause {\n  constructor(\n    public readonly parameter: string,\n    public readonly op: \"$eq\" | \"$gt\" | \"$lt\" | \"$gte\" | \"$lte\",\n    public readonly value: T,\n    public readonly type: \"\" | \"bool\" | \"number\" | \"string\" | \"date\" | \"oid\",\n  ) {\n    super();\n  }\n\n  toQuery(truthy: boolean): Filter<unknown> {\n    let v: any = this.value;\n    if (this.type === \"date\" && typeof v === \"number\")\n      v = { $date: new Date(v).toISOString() };\n    if (this.type === \"oid\" && typeof v === \"string\") v = { $oid: v };\n\n    if (truthy) return { [this.parameter]: { [this.op]: v } };\n    else if (this.op === \"$eq\") return { [this.parameter]: { $ne: v } };\n    else return { [this.parameter]: { $not: { [this.op]: v } } };\n  }\n}\n\nclass MongoClauseType extends MongoClause {\n  constructor(\n    public readonly parameter: string,\n    public readonly type: string,\n  ) {\n    super();\n  }\n\n  toQuery(truthy: boolean): Filter<unknown> {\n    if (truthy) return { [this.parameter]: { $type: this.type } };\n    else return { [this.parameter]: { $not: { $type: this.type } } };\n  }\n}\n\nclass MongoClauseLike extends MongoClause {\n  readonly pattern: string[];\n  constructor(\n    public readonly parameter: string,\n    pat: string,\n    esc: string,\n    public readonly caseSensitive: boolean,\n  ) {\n    super();\n    this.pattern = parseLikePattern(pat, esc);\n  }\n\n  toQuery(truthy: boolean): Filter<unknown> {\n    const convChars = {\n      \"-\": \"\\\\-\",\n      \"/\": \"\\\\/\",\n      \"\\\\\": \"\\\\/\",\n      \"^\": \"\\\\^\",\n      $: \"\\\\$\",\n      \"*\": \"\\\\*\",\n      \"+\": \"\\\\+\",\n      \"?\": \"\\\\?\",\n      \".\": \"\\\\.\",\n      \"(\": \"\\\\(\",\n      \")\": \"\\\\)\",\n      \"|\": \"\\\\|\",\n      \"[\": \"\\\\[\",\n      \"]\": \"\\\\]\",\n      \"{\": \"\\\\{\",\n      \"}\": \"\\\\}\",\n      \"\\\\%\": \".*\",\n      \"\\\\_\": \".\",\n    };\n\n    const chars = this.pattern.map((c) => convChars[c] ?? c);\n    chars[0] = chars[0] === \".*\" ? \"\" : \"^\" + chars[0];\n    const l = chars.length - 1;\n    chars[l] = [\".*\", \"\"].includes(chars[l]) ? \"\" : chars[l] + \"$\";\n    const pattern = chars.join(\"\");\n    const options = this.caseSensitive ? \"s\" : \"is\";\n    if (truthy) {\n      return { [this.parameter]: { $regularExpression: { options, pattern } } };\n    } else {\n      return {\n        [this.parameter]: {\n          $not: { $regularExpression: { options, pattern } },\n        },\n      };\n    }\n  }\n}\n\nclass MongoSynthContext extends SynthContextBase<Clause, MongoClause> {\n  constructor(private readonly collection: string) {\n    super();\n  }\n\n  getMinterms(clause: Clause, res: number): number[][] {\n    res = res & 0b111;\n    if (res & (res - 1)) return complement(this.getMinterms(clause, ~res));\n\n    const exp = clause.expression();\n    if (res === 0b001) {\n      const minterms: number[][] = [];\n      for (const dep of clause.getNullables()) {\n        const e = dep.operand.expression();\n        const param = getParam(e, this.collection);\n        if (param.startsWith(\"Tags.\") && this.collection === \"devices\") {\n          const t = decodeTag(param.slice(5));\n          const c = new MongoClauseArray(\"_tags\", t);\n          minterms.push([this.getVar(c) << 1]);\n          continue;\n        }\n        const c = new MongoClauseCompare(param, \"$eq\", null, \"\");\n        minterms.push([(this.getVar(c) << 1) ^ 1]);\n      }\n      return minterms;\n    }\n\n    if (!(exp instanceof Expression.Binary))\n      throw new Error(\"Invalid query expression\");\n\n    const truthy = res === 0b100;\n\n    if ([\">\", \"<\", \"=\"].includes(exp.operator)) {\n      const param = getParam(exp.left, this.collection);\n\n      if (!(exp.right instanceof Expression.Literal))\n        throw new Error(`Right-hand operand must be a literal value`);\n\n      let rhs = exp.right.value;\n      if (typeof rhs === \"boolean\") rhs = +rhs;\n\n      if (\n        param.startsWith(\"Tags.\") &&\n        this.collection === \"devices\" &&\n        exp.operator === \"=\"\n      ) {\n        const t = decodeTag(param.slice(5));\n        const c = new MongoClauseArray(\"_tags\", t);\n        if (typeof rhs === \"string\") rhs = 2;\n        if (exp.operator === \"=\") {\n          if ((rhs === 1) !== truthy) return [];\n        } else if (exp.operator === \">\") {\n          if ((truthy && rhs >= 1) || (!truthy && rhs < 1)) return [];\n        } else if (exp.operator === \"<\") {\n          if ((truthy && rhs <= 1) || (!truthy && rhs > 1)) return [];\n        }\n        return [[(this.getVar(c) << 1) ^ 1]];\n      }\n\n      let op: \"$gt\" | \"$lt\" | \"$eq\" | \"$gte\" | \"$lte\";\n      if (exp.operator === \"=\") op = \"$eq\";\n      else if (exp.operator === \">\") op = truthy ? \"$gt\" : \"$lte\";\n      else if (exp.operator === \"<\") op = truthy ? \"$lt\" : \"$gte\";\n\n      const possibleTypes = new Set(getTypes(param, this.collection));\n      const clauses: MongoClause[] = [];\n\n      if (typeof rhs === \"number\") {\n        if (possibleTypes.has(\"number\"))\n          clauses.push(new MongoClauseCompare(param, op, rhs, \"number\"));\n\n        if (possibleTypes.has(\"date\"))\n          clauses.push(new MongoClauseCompare(param, op, rhs, \"date\"));\n        if (possibleTypes.has(\"bool\") && (rhs === 0 || rhs === 1))\n          clauses.push(new MongoClauseCompare(param, op, !!rhs, \"bool\"));\n      } else if (typeof rhs === \"string\") {\n        if (possibleTypes.has(\"string\"))\n          clauses.push(new MongoClauseCompare(param, op, rhs, \"string\"));\n\n        if (possibleTypes.has(\"oid\")) {\n          const oid = roundOid(rhs, op === \"$lt\" || op === \"$lte\");\n          if (oid && (op !== \"$eq\" || oid === rhs))\n            clauses.push(new MongoClauseCompare(param, op, oid, \"oid\"));\n        }\n      }\n\n      if (op === \"$eq\" && !truthy) {\n        clauses.push(new MongoClauseCompare(param, \"$eq\", null, \"\"));\n        return [clauses.map((c) => this.getVar(c) << 1)];\n      }\n\n      // In the following clauses we could use $type operator, but we want the\n      // final query to use comparison operators to check for type because the\n      // $type operator in MongoDB doesn't use indexes\n      if (typeof rhs === \"number\") {\n        if (possibleTypes.has(\"bool\")) {\n          if (\n            (rhs > 1 && (op === \"$lt\" || op === \"$lte\")) ||\n            (rhs < 0 && (op === \"$gt\" || op === \"$gte\"))\n          )\n            clauses.push(new MongoClauseCompare(param, \"$gte\", false, \"bool\"));\n        }\n\n        if (op === \"$gt\" || op === \"$gte\") {\n          if (possibleTypes.has(\"string\"))\n            clauses.push(new MongoClauseCompare(param, \"$gte\", \"\", \"string\"));\n\n          if (possibleTypes.has(\"oid\")) {\n            clauses.push(\n              new MongoClauseCompare(\n                param,\n                \"$gte\",\n                \"000000000000000000000000\",\n                \"oid\",\n              ),\n            );\n          }\n        }\n      } else if (typeof rhs === \"string\") {\n        if (op === \"$lt\" || op === \"$lte\") {\n          if (possibleTypes.has(\"bool\"))\n            clauses.push(new MongoClauseCompare(param, \"$gte\", false, \"bool\"));\n\n          if (possibleTypes.has(\"number\")) {\n            clauses.push(new MongoClauseCompare(param, \"$gte\", 0, \"number\"));\n            clauses.push(new MongoClauseCompare(param, \"$lt\", 0, \"number\"));\n          }\n          if (possibleTypes.has(\"date\")) {\n            clauses.push(new MongoClauseCompare(param, \"$gte\", 0, \"date\"));\n            clauses.push(new MongoClauseCompare(param, \"$lt\", 0, \"date\"));\n          }\n        }\n      }\n\n      return clauses.map((c) => [(this.getVar(c) << 1) ^ 1]);\n    } else if (exp.operator === \"LIKE\") {\n      if (\n        !(\n          exp.right instanceof Expression.Literal &&\n          typeof exp.right.value === \"string\"\n        )\n      )\n        throw new Error(\"Right-hand operand of 'LIKE' must be a string\");\n      const pat = exp.right.value;\n\n      let p = exp.left;\n      let caseSensitive = true;\n      if (\n        p instanceof Expression.FunctionCall &&\n        [\"UPPER\", \"LOWER\"].includes(p.name)\n      ) {\n        if (p.name === \"UPPER\" && pat !== pat.toUpperCase())\n          return truthy ? [] : [[]];\n        if (p.name === \"LOWER\" && pat !== pat.toLowerCase())\n          return truthy ? [] : [[]];\n        caseSensitive = false;\n        p = p.args[0];\n      }\n\n      const param = getParam(p, this.collection);\n      const c = new MongoClauseLike(param, pat, null, caseSensitive);\n      if (truthy) return [[(this.getVar(c) << 1) ^ 1]];\n      const typeClause = new MongoClauseCompare(param, \"$gte\", \"\", \"string\");\n      const r = [[this.getVar(c) << 1, (this.getVar(typeClause) << 1) ^ 1]];\n      return r;\n    } else {\n      throw new Error(\"Invalid query expression\");\n    }\n  }\n\n  getDcSet(minterms: Minterm[]): number[][] {\n    const dcSet: number[][] = [];\n\n    const vars = new Set(minterms.flat().map((l) => l >> 1));\n    const clauses = Array.from(vars).map((v) => this.getClause(v));\n\n    for (const [parameter, clauses2] of groupBy(clauses, (c) => c.parameter)) {\n      const comparisons = clauses2.filter(\n        (c) => c instanceof MongoClauseCompare && c.value !== null,\n      ) as MongoClauseCompare<unknown>[];\n\n      for (const [type, clauses3] of groupBy(comparisons, (c) => c.type)) {\n        const isType = this.getVar(new MongoClauseType(parameter, type));\n        const values = new Set(clauses3.map((c) => c.value));\n        const valuesSorted = Array.from(values).sort((a, b) =>\n          a > b ? 1 : -1,\n        );\n        for (const [i, v] of valuesSorted.entries()) {\n          const eq = this.getVar(\n            new MongoClauseCompare(parameter, \"$eq\", v, type),\n          );\n          const gt = this.getVar(\n            new MongoClauseCompare(parameter, \"$gt\", v, type),\n          );\n          const lt = this.getVar(\n            new MongoClauseCompare(parameter, \"$lt\", v, type),\n          );\n          const gte = this.getVar(\n            new MongoClauseCompare(parameter, \"$gte\", v, type),\n          );\n          const lte = this.getVar(\n            new MongoClauseCompare(parameter, \"$lte\", v, type),\n          );\n\n          if (type === \"bool\") {\n            if (v === false) dcSet.push([(lt << 1) ^ 1]);\n            else if (v === true) dcSet.push([(gt << 1) ^ 1]);\n          } else if (type === \"string\") {\n            if (v === \"\") dcSet.push([(lt << 1) ^ 1]);\n          } else if (type === \"oid\") {\n            if (v < \"000000000000000000000000\") dcSet.push([(lte << 1) ^ 1]);\n            else if (v === \"000000000000000000000000\")\n              dcSet.push([(lt << 1) ^ 1]);\n            else if (v > \"ffffffffffffffffffffffff\")\n              dcSet.push([(gte << 1) ^ 1]);\n            else if (v === \"ffffffffffffffffffffffff\")\n              dcSet.push([(gt << 1) ^ 1]);\n          }\n          dcSet.push([(lt << 1) ^ 1, (gte << 1) ^ 1]);\n          dcSet.push([(gt << 1) ^ 1, (gte << 1) ^ 0]);\n          dcSet.push([(eq << 1) ^ 0, (lt << 1) ^ 0, (lte << 1) ^ 1]);\n          dcSet.push([(eq << 1) ^ 1, (gte << 1) ^ 0]);\n          dcSet.push([(eq << 1) ^ 1, (gt << 1) ^ 1]);\n\n          if (i === 0) {\n            dcSet.push([(isType << 1) ^ 0, (gte << 1) ^ 1]);\n            dcSet.push([(isType << 1) ^ 0, (lt << 1) ^ 1]);\n          } else {\n            const gt2 = this.getVar(\n              new MongoClauseCompare(\n                parameter,\n                \"$gt\",\n                valuesSorted[i - 1],\n                type,\n              ),\n            );\n            const lte2 = this.getVar(\n              new MongoClauseCompare(\n                parameter,\n                \"$lte\",\n                valuesSorted[i - 1],\n                type,\n              ),\n            );\n            dcSet.push([(gt2 << 1) ^ 0, (gte << 1) ^ 1]);\n            dcSet.push([(gt2 << 1) ^ 0, (lte2 << 1) ^ 0, (lt << 1) ^ 1]);\n          }\n\n          if (i === valuesSorted.length - 1)\n            dcSet.push([(isType << 1) ^ 1, (gt << 1) ^ 0, (lte << 1) ^ 0]);\n        }\n      }\n\n      const likes = clauses2.filter(\n        (c) => c instanceof MongoClauseLike,\n      ) as MongoClauseLike[];\n      if (likes.length) {\n        const isType = this.getVar(new MongoClauseType(parameter, \"string\"));\n        for (let i1 = 0; i1 < likes.length; ++i1) {\n          const l1 = likes[i1];\n          let p1 = l1.pattern;\n          dcSet.push([(this.getVar(l1) << 1) ^ 1, (isType << 1) ^ 0]);\n          for (let i2 = i1 + 1; i2 < likes.length; ++i2) {\n            const l2 = likes[i2];\n            let p2 = l2.pattern;\n            if (!l1.caseSensitive || !l2.caseSensitive) {\n              p1 = p1.map((c) => c.toLowerCase());\n              p2 = p2.map((c) => c.toLowerCase());\n            }\n            if (likeDisjoint(p1, p2)) {\n              dcSet.push([\n                (this.getVar(l1) << 1) ^ 1,\n                (this.getVar(l2) << 1) ^ 1,\n              ]);\n            } else if (\n              (!l1.caseSensitive || l2.caseSensitive) &&\n              likeImplies(p1, p2)\n            ) {\n              dcSet.push([\n                (this.getVar(l1) << 1) ^ 0,\n                (this.getVar(l2) << 1) ^ 1,\n              ]);\n            } else if (\n              (!l2.caseSensitive || l1.caseSensitive) &&\n              likeImplies(p2, p1)\n            ) {\n              dcSet.push([\n                (this.getVar(l1) << 1) ^ 1,\n                (this.getVar(l2) << 1) ^ 0,\n              ]);\n            }\n          }\n        }\n      }\n\n      const isNull = this.getVar(\n        new MongoClauseCompare(parameter, \"$eq\", null, \"\"),\n      );\n\n      const types = getTypes(parameter, this.collection).map((t) =>\n        this.getVar(new MongoClauseType(parameter, t)),\n      );\n\n      dcSet.push([(isNull << 1) ^ 0, ...types.map((t) => (t << 1) ^ 0)]);\n      for (let i = 0; i < types.length; ++i) {\n        const t1 = types[i];\n        dcSet.push([(t1 << 1) ^ 1, (isNull << 1) ^ 1]);\n        for (let j = i + 1; j < types.length; ++j) {\n          const t2 = types[j];\n          dcSet.push([(t1 << 1) ^ 1, (t2 << 1) ^ 1]);\n        }\n      }\n\n      if (parameter === \"_id\") dcSet.push([(isNull << 1) ^ 1]);\n    }\n    return dcSet;\n  }\n\n  canRaise(i: number, s: Set<number>): boolean {\n    if (!(i & 1)) return true;\n    const c = this.getClause(i >> 1);\n    if (c instanceof MongoClauseCompare) {\n      for (const j of s) {\n        if (j === i) continue;\n        const c2 = this.getClause(j >> 1);\n        if (c2.parameter !== c.parameter) continue;\n        if (!(j & 1)) return false;\n        if (c2 instanceof MongoClauseType) return false;\n      }\n    }\n    return true;\n  }\n\n  toQuery(minterms: Minterm[]): Filter<unknown> {\n    const or = [];\n\n    const ejsonOps = [\"$oid\", \"$date\", \"$regularExpression\"];\n\n    for (const minterm of minterms) {\n      const query = {};\n      loop: for (const clause of minterm) {\n        if (!minterm.length) return {};\n        const negate = !!(clause & 1);\n        const c = this.getClause(clause >> 1);\n        const q = c.toQuery(negate);\n        if (Object.keys(q).length !== 1)\n          throw new Error(\"Invalid query expression\");\n        const [param, value] = Object.entries(q)[0];\n        const dests = [query, ...(query[\"$and\"] ?? [])];\n        for (const dest of dests) {\n          if (!(param in dest)) {\n            dest[param] = value;\n            continue loop;\n          }\n\n          let src = value;\n          let dst = dest[param];\n          if (Object.getPrototypeOf(src).constructor !== Object) continue;\n          if (Object.getPrototypeOf(dst).constructor !== Object) continue;\n          let srcKeys = Object.keys(src);\n          let dstKeys = Object.keys(dst);\n\n          if ([...srcKeys, ...dstKeys].every((k) => k === \"$not\")) {\n            src = src[\"$not\"];\n            dst = dst[\"$not\"];\n            if (Object.getPrototypeOf(src).constructor !== Object) continue;\n            if (Object.getPrototypeOf(dst).constructor !== Object) continue;\n            srcKeys = Object.keys(src);\n            dstKeys = Object.keys(dst);\n          }\n\n          if (srcKeys.some((k) => dstKeys.includes(k))) continue;\n\n          // Don't mix regular operators with EJSON special operators\n          if (srcKeys.some((k) => ejsonOps.includes(k))) continue;\n          if (dstKeys.some((k) => ejsonOps.includes(k))) continue;\n\n          Object.assign(dst, src);\n          continue loop;\n        }\n\n        query[\"$and\"] ??= [];\n        query[\"$and\"].push({ [param]: value });\n      }\n      or.push(query);\n    }\n\n    if (or.length === 1) return or[0];\n    return { $or: or };\n  }\n}\n\nexport function toMongoQuery(\n  exp: Expression,\n  resource: string,\n): Filter<unknown> | false {\n  exp = normalize(exp);\n  const clause = Clause.fromExpression(normalize(exp));\n  const context = new MongoSynthContext(resource);\n  const minterms = clause.getMinterms(context, 0b100);\n  const minimized = context.minimize(minterms);\n  if (!minimized.length) return false;\n  return EJSON.deserialize(context.toQuery(minimized));\n}\n\nexport function validQuery(exp: Expression, resource: string): void {\n  const clause = Clause.fromExpression(normalize(exp));\n  const context = new MongoSynthContext(resource);\n  clause.getMinterms(context, 0b100);\n}\n"
  },
  {
    "path": "lib/db/types.ts",
    "content": "import { ObjectId } from \"mongodb\";\nimport { FaultStruct } from \"../types.ts\";\nimport { Value } from \"../common/expression.ts\";\n\nexport interface Fault {\n  _id: string;\n  device: string;\n  channel: string;\n  timestamp: Date;\n  provisions: string;\n  retries: number;\n  code: string;\n  message: string;\n  detail?:\n    | FaultStruct\n    | {\n        name: string;\n        message: string;\n        stack?: string;\n      };\n  expiry?: Date;\n}\n\ninterface TaskBase {\n  _id: ObjectId;\n  timestamp?: Date;\n  expiry?: Date;\n  name: string;\n  device: string;\n}\n\nexport interface View {\n  _id: string;\n  script: string;\n}\n\ninterface TaskGetParameterValues extends TaskBase {\n  name: \"getParameterValues\";\n  parameterNames: string[];\n}\n\ninterface TaskSetParameterValues extends TaskBase {\n  name: \"setParameterValues\";\n  parameterValues: [string, string | number | boolean, string?][];\n}\n\ninterface TaskRefreshObject extends TaskBase {\n  name: \"refreshObject\";\n  objectName: string;\n}\n\ninterface TaskReboot extends TaskBase {\n  name: \"reboot\";\n}\n\ninterface TaskFactoryReset extends TaskBase {\n  name: \"factoryReset\";\n}\n\ninterface TaskDownload extends TaskBase {\n  name: \"download\";\n  fileType: string;\n  fileName: string;\n  targetFileName?: string;\n}\n\ninterface TaskAddObject extends TaskBase {\n  name: \"addObject\";\n  objectName: string;\n  parameterValues: [string, string | number | boolean, string?][];\n}\n\ninterface TaskDeleteObject extends TaskBase {\n  name: \"deleteObject\";\n  objectName: string;\n}\n\ninterface TaskProvisions extends TaskBase {\n  name: \"provisions\";\n  provisions?: [string, ...Value[]][];\n}\n\nexport type Task =\n  | TaskGetParameterValues\n  | TaskSetParameterValues\n  | TaskRefreshObject\n  | TaskReboot\n  | TaskFactoryReset\n  | TaskDownload\n  | TaskAddObject\n  | TaskDeleteObject\n  | TaskProvisions;\n\nexport interface Operation {\n  _id: string;\n  name: string;\n  timestamp: Date;\n  channels: string;\n  retries: string;\n  provisions: string;\n  args: string;\n}\n\nexport interface Config {\n  _id: string;\n  value: string;\n}\n\nexport interface Cache {\n  _id: string;\n  value: string;\n  timestamp: Date;\n  expire: Date;\n}\n\nexport interface Device {\n  _id: string;\n  _lastInform: Date;\n  _registered: Date;\n  _tags?: string[];\n  _timestamp?: Date;\n}\n\ntype Configuration =\n  | { type: \"age\"; name: string; age: number }\n  | { type: \"value\"; name: string; value: boolean | number | string }\n  | { type: \"add_tag\"; tag: string }\n  | { type: \"delete_tag\"; tag: string }\n  | { type: \"add_object\"; name: string; object: string }\n  | { type: \"delete_object\"; name: string; object: string }\n  | { type: \"provision\"; name: string; args?: string[] };\n\nexport interface Preset {\n  _id: string;\n  weight: number;\n  channel: string;\n  events: Record<string, boolean>;\n  configurations: Configuration[];\n}\n\nexport interface Object {\n  _id: string;\n}\n\nexport interface Provision {\n  _id: string;\n  script: string;\n}\n\nexport interface VirtualParameter {\n  _id: string;\n  script: string;\n}\n\nexport interface File {\n  _id: string;\n  length: number;\n  filename: string;\n  uploadDate: Date;\n  metadata?: {\n    fileType?: string;\n    oui?: string;\n    productClass?: string;\n    version?: string;\n  };\n}\n\nexport interface Permission {\n  _id: string;\n  role: string;\n  resource: string;\n  access: 1 | 2 | 3;\n  filter?: string;\n  validate?: string;\n}\n\nexport interface User {\n  _id: string;\n  password: string;\n  roles: string;\n  salt: string;\n}\n\nexport interface Lock {\n  _id: string;\n  value: string;\n  timestamp: Date;\n  expire: Date;\n}\n"
  },
  {
    "path": "lib/db/util.ts",
    "content": "import Expression, { Value } from \"../common/expression.ts\";\nimport Path from \"../common/path.ts\";\nimport { encodeTag } from \"../util.ts\";\n\n// Optimize projection by removing overlaps\n// This can modify the object\nexport function optimizeProjection(obj: { [path: string]: 1 }): {\n  [path: string]: 1;\n} {\n  if (obj[\"\"]) return { \"\": obj[\"\"] };\n\n  const keys = Object.keys(obj).sort();\n  if (keys.length <= 1) return obj;\n\n  for (let i = 1; i < keys.length; ++i) {\n    const a = keys[i - 1];\n    const b = keys[i];\n    if (b.startsWith(a)) {\n      if (b.charAt(a.length) === \".\" || b.charAt(a.length - 1) === \".\") {\n        delete obj[b];\n        keys.splice(i--, 1);\n      }\n    }\n  }\n  return obj;\n}\n\nexport function convertOldPrecondition(q: Record<string, unknown>): Expression {\n  function recursive(_query): Expression {\n    let res: Expression = new Expression.Literal(true);\n    for (const [k, v] of Object.entries(_query)) {\n      if (k[0] === \"$\") {\n        if (k === \"$and\") {\n          for (const vv of Object.values(v))\n            res = Expression.and(res, recursive(vv));\n        } else if (k === \"$or\") {\n          let or: Expression = new Expression.Literal(false);\n          for (const vv of Object.values(v))\n            or = Expression.or(or, recursive(vv));\n          res = Expression.and(res, or);\n        } else {\n          throw new Error(`Operator ${k} not supported`);\n        }\n      } else if (k === \"_tags\") {\n        if (typeof v === \"object\") {\n          if (Array.isArray(v)) throw new Error(`Invalid type`);\n          for (const [op, val] of Object.entries(v)) {\n            if (op === \"$ne\") {\n              if (typeof v[\"$ne\"] !== \"string\")\n                throw new Error(\"Only string values are allowed for _tags\");\n              res = Expression.and(\n                res,\n                new Expression.Unary(\n                  \"IS NULL\",\n                  new Expression.Parameter(\n                    Path.parse(`Tags.${encodeTag(val)}`),\n                  ),\n                ),\n              );\n            } else if (op === \"$eq\") {\n              if (typeof v[\"$eq\"] !== \"string\")\n                throw new Error(\"Only string values are allowed for _tags\");\n              res = Expression.and(\n                res,\n                new Expression.Unary(\n                  \"IS NOT NULL\",\n                  new Expression.Parameter(\n                    Path.parse(`Tags.${encodeTag(val)}`),\n                  ),\n                ),\n              );\n            } else {\n              throw new Error(`Invalid tag query`);\n            }\n          }\n        } else {\n          res = Expression.and(\n            res,\n            new Expression.Unary(\n              \"IS NOT NULL\",\n              new Expression.Parameter(\n                Path.parse(`Tags.${encodeTag(v as string)}`),\n              ),\n            ),\n          );\n        }\n      } else if (k.startsWith(\"Tags.\")) {\n        let exists: boolean;\n        if (typeof v === \"boolean\") exists = v;\n        else if (v.hasOwnProperty(\"$eq\")) exists = !!v[\"$eq\"];\n        else if (v.hasOwnProperty(\"$ne\")) exists = !v[\"$ne\"];\n        else if (v.hasOwnProperty(\"$exists\")) exists = !!v[\"$exists\"];\n        else throw new Error(`Invalid tag query`);\n\n        res = Expression.and(\n          res,\n          new Expression.Unary(\n            exists ? \"IS NOT NULL\" : \"IS NULL\",\n            new Expression.Parameter(Path.parse(k)),\n          ),\n        );\n      } else if (typeof v === \"object\") {\n        if (Array.isArray(v)) throw new Error(`Invalid type`);\n        for (const [kk, vv] of Object.entries(v)) {\n          if (kk === \"$eq\") {\n            res = Expression.and(\n              res,\n              new Expression.Binary(\n                \"=\",\n                new Expression.Parameter(Path.parse(k)),\n                new Expression.Literal(vv),\n              ),\n            );\n          } else if (kk === \"$ne\") {\n            const p = new Expression.Parameter(Path.parse(k));\n            res = Expression.and(\n              res,\n              Expression.or(\n                new Expression.Binary(\"<>\", p, new Expression.Literal(vv)),\n                new Expression.Unary(\"IS NULL\", p),\n              ),\n            );\n          } else if (kk === \"$lt\") {\n            res = Expression.and(\n              res,\n              new Expression.Binary(\n                \"<\",\n                new Expression.Parameter(Path.parse(k)),\n                new Expression.Literal(vv),\n              ),\n            );\n          } else if (kk === \"$lte\") {\n            res = Expression.and(\n              res,\n              new Expression.Binary(\n                \"<=\",\n                new Expression.Parameter(Path.parse(k)),\n                new Expression.Literal(vv),\n              ),\n            );\n          } else if (kk === \"$gt\") {\n            res = Expression.and(\n              res,\n              new Expression.Binary(\n                \">\",\n                new Expression.Parameter(Path.parse(k)),\n                new Expression.Literal(vv),\n              ),\n            );\n          } else if (kk === \"$gte\") {\n            res = Expression.and(\n              res,\n              new Expression.Binary(\n                \">=\",\n                new Expression.Parameter(Path.parse(k)),\n                new Expression.Literal(vv),\n              ),\n            );\n          } else {\n            throw new Error(`Operator ${kk} not supported`);\n          }\n          if (![\"string\", \"number\", \"boolean\"].includes(typeof vv))\n            throw new Error(`Invalid value for ${kk} operator`);\n        }\n      } else {\n        res = Expression.and(\n          res,\n          new Expression.Binary(\n            \"=\",\n            new Expression.Parameter(Path.parse(k)),\n            new Expression.Literal(v as Value),\n          ),\n        );\n      }\n    }\n    return res;\n  }\n\n  // empty filter\n  if (!Object.keys(q).length) return new Expression.Literal(true);\n\n  return recursive(q);\n}\n"
  },
  {
    "path": "lib/debug.ts",
    "content": "import { IncomingMessage, ServerResponse, ClientRequest } from \"node:http\";\nimport { Socket } from \"node:net\";\nimport { appendFileSync } from \"node:fs\";\nimport { stringify } from \"./common/yaml.ts\";\nimport * as config from \"./config.ts\";\nimport { getSocketEndpoints } from \"./server.ts\";\n\nconst DEBUG_FILE = \"\" + config.get(\"DEBUG_FILE\");\nconst DEBUG_FORMAT = \"\" + config.get(\"DEBUG_FORMAT\");\n\nconst connectionTimestamps = new WeakMap<Socket, Date>();\n\nfunction getConnectionTimestamp(connection: Socket): Date {\n  let t = connectionTimestamps.get(connection);\n  if (!t) {\n    t = new Date();\n    connectionTimestamps.set(connection, t);\n  }\n  return t;\n}\n\nexport function incomingHttpRequest(\n  httpRequest: IncomingMessage,\n  deviceId: string,\n  body: string,\n): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const con = httpRequest.socket;\n  const socketEndpoints = getSocketEndpoints(con);\n  const msg = {\n    event: \"incoming HTTP request\",\n    timestamp: now,\n    remoteAddress: socketEndpoints.remoteAddress,\n    deviceId: deviceId,\n    connection: getConnectionTimestamp(con),\n    localPort: socketEndpoints.localPort,\n    method: httpRequest.method,\n    url: httpRequest.url,\n    headers: httpRequest.headers,\n    body: body,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function outgoingHttpResponse(\n  httpResponse: ServerResponse,\n  deviceId: string,\n  body: string,\n): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const con = httpResponse.socket;\n  const socketEndpoints = getSocketEndpoints(con);\n  const msg = {\n    event: \"outgoing HTTP response\",\n    timestamp: now,\n    remoteAddress: socketEndpoints.remoteAddress,\n    deviceId: deviceId,\n    connection: getConnectionTimestamp(con),\n    statusCode: httpResponse.statusCode,\n    headers: httpResponse.getHeaders(),\n    body: body,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function outgoingHttpRequest(\n  httpRequest: ClientRequest,\n  deviceId: string,\n  method: \"GET\" | \"PUT\" | \"POST\" | \"DELETE\",\n  url: URL,\n  body: string,\n): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const con = httpRequest.socket;\n  const msg = {\n    event: \"outgoing HTTP request\",\n    timestamp: now,\n    remoteAddress: con.remoteAddress,\n    deviceId: deviceId,\n    connection: getConnectionTimestamp(con),\n    remotePort: url.port,\n    method: method,\n    url: url.pathname + url.search,\n    headers: httpRequest.getHeaders(),\n    body: body,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function outgoingHttpRequestError(\n  httpRequest: ClientRequest,\n  deviceId: string,\n  method: \"GET\" | \"PUT\" | \"POST\" | \"DELETE\",\n  url: URL,\n  err: Error,\n): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const msg = {\n    event: \"outgoing HTTP request\",\n    timestamp: now,\n    remoteAddress: url.hostname,\n    deviceId: deviceId,\n    connection: null,\n    remotePort: url.port,\n    method: method,\n    url: url.pathname + url.search,\n    headers: httpRequest.getHeaders(),\n    error: err.message,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function incomingHttpResponse(\n  httpResponse: IncomingMessage,\n  deviceId: string,\n  body: string,\n): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const con = httpResponse.socket;\n  const msg = {\n    event: \"incoming HTTP response\",\n    timestamp: now,\n    remoteAddress: con.remoteAddress,\n    deviceId: deviceId,\n    connection: getConnectionTimestamp(httpResponse.socket),\n    statusCode: httpResponse.statusCode,\n    headers: httpResponse.headers,\n    body: body,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function outgoingUdpMessage(\n  remoteAddress: string,\n  deviceId: string,\n  remotePort: number,\n  body: string,\n): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const msg = {\n    event: \"outgoing UDP message\",\n    timestamp: now,\n    remoteAddress: remoteAddress,\n    deviceId: deviceId,\n    remotePort: remotePort,\n    body: body,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function clientError(remoteAddress: string, err: Error): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const msg = {\n    event: \"client error\",\n    timestamp: now,\n    remoteAddress: remoteAddress,\n    error: err.message,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function outgoingXmppStanza(deviceId: string, body: string): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const msg = {\n    event: \"outgoing XMPP stanza\",\n    timestamp: now,\n    deviceId: deviceId,\n    body: body,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n\nexport function incomingXmppStanza(deviceId: string, body: string): void {\n  if (!DEBUG_FILE) return;\n  const now = new Date();\n  const msg = {\n    event: \"incoming XMPP stanza\",\n    timestamp: now,\n    deviceId: deviceId,\n    body: body,\n  };\n\n  if (DEBUG_FORMAT === \"yaml\")\n    appendFileSync(DEBUG_FILE, \"---\\n\" + stringify(msg));\n  else if (DEBUG_FORMAT === \"json\")\n    appendFileSync(DEBUG_FILE, JSON.stringify(msg) + \"\\n\");\n  else throw new Error(`Unrecognized DEBUG_FORMAT option`);\n}\n"
  },
  {
    "path": "lib/default-provisions.ts",
    "content": "import Path from \"./common/path.ts\";\nimport * as config from \"./config.ts\";\nimport * as device from \"./device.ts\";\nimport * as scheduling from \"./scheduling.ts\";\nimport { SessionContext, Declaration } from \"./types.ts\";\n\nconst MAX_DEPTH = +config.get(\"MAX_DEPTH\");\n\nexport function refresh(\n  sessionContext: SessionContext,\n  provision: (string | number | boolean)[],\n  declarations: Declaration[],\n): boolean {\n  if (\n    (provision.length !== 2 || typeof provision[1] !== \"string\") &&\n    (provision.length !== 3 ||\n      typeof provision[1] !== \"string\" ||\n      typeof provision[2] !== \"number\") &&\n    (provision.length < 4 ||\n      typeof provision[1] !== \"string\" ||\n      typeof provision[2] !== \"number\" ||\n      typeof provision[3] !== \"boolean\")\n  )\n    throw new Error(\"Invalid arguments\");\n\n  const every = 1000 * ((provision[2] as number) || 1);\n  const offset = scheduling.variance(sessionContext.deviceId, every);\n  const t = scheduling.interval(sessionContext.timestamp, every, offset);\n\n  let attrGet;\n  let refreshChildren;\n  if (provision[3] == null) {\n    refreshChildren = true;\n    attrGet = { object: 1, writable: 1, value: t };\n  } else {\n    attrGet = {};\n    refreshChildren = !!provision[3];\n    for (const a of provision.slice(4)) attrGet[a as string] = t;\n  }\n\n  let path = Path.parse(provision[1]);\n  let l = path.length;\n  if (refreshChildren) {\n    const segments = path.segments.slice();\n    l = segments.length;\n    segments.length = MAX_DEPTH;\n    segments.fill(\"*\", l);\n    path = Path.parse(segments.join(\".\"));\n  }\n\n  for (let i = l; i <= path.length; ++i) {\n    declarations.push({\n      path: path.slice(0, i),\n      pathGet: t,\n      pathSet: null,\n      attrGet: attrGet,\n      attrSet: null,\n      defer: true,\n    });\n  }\n\n  return true;\n}\n\nexport function value(\n  sessionContext: SessionContext,\n  provision: (string | number | boolean)[],\n  declarations: Declaration[],\n): boolean {\n  if (\n    provision.length < 3 ||\n    provision.length > 4 ||\n    typeof provision[1] !== \"string\"\n  )\n    throw new Error(\"Invalid arguments\");\n\n  let attr: string, val: any;\n\n  if (provision.length === 3) {\n    attr = \"value\";\n    val = provision[2];\n  } else {\n    attr = (provision[2] as string) || \"\";\n    val = provision[3];\n  }\n\n  if (attr === \"accessList\") {\n    val = (val || \"\")\n      .split(\",\")\n      .map((s) => s.trim())\n      .filter((s) => !!s);\n  } else if (attr === \"value\") {\n    val = [val];\n  }\n\n  declarations.push({\n    path: Path.parse(provision[1]),\n    pathGet: 1,\n    pathSet: null,\n    attrGet: { [attr]: 1 },\n    attrSet: { [attr]: val },\n    defer: true,\n  });\n\n  return true;\n}\n\nexport function tag(\n  sessionContext: SessionContext,\n  provision: (string | number | boolean)[],\n  declarations: Declaration[],\n): boolean {\n  if (\n    provision.length !== 3 ||\n    typeof provision[1] !== \"string\" ||\n    typeof provision[2] !== \"boolean\"\n  )\n    throw new Error(\"Invalid arguments\");\n\n  declarations.push({\n    path: Path.parse(`Tags.${provision[1]}`),\n    pathGet: 1,\n    pathSet: null,\n    attrGet: { value: 1 },\n    attrSet: { value: [provision[2]] },\n    defer: true,\n  });\n\n  return true;\n}\n\nexport function reboot(\n  sessionContext: SessionContext,\n  provision: (string | number | boolean)[],\n  declarations: Declaration[],\n): boolean {\n  if (provision.length !== 1) throw new Error(\"Invalid arguments\");\n\n  declarations.push({\n    path: Path.parse(\"Reboot\"),\n    pathGet: 1,\n    pathSet: null,\n    attrGet: { value: 1 },\n    attrSet: { value: [sessionContext.timestamp] },\n    defer: true,\n  });\n\n  return true;\n}\n\nexport function reset(\n  sessionContext: SessionContext,\n  provision: (string | number | boolean)[],\n  declarations: Declaration[],\n): boolean {\n  if (provision.length !== 1) throw new Error(\"Invalid arguments\");\n\n  declarations.push({\n    path: Path.parse(\"FactoryReset\"),\n    pathGet: 1,\n    pathSet: null,\n    attrGet: { value: 1 },\n    attrSet: { value: [sessionContext.timestamp] },\n    defer: true,\n  });\n\n  return true;\n}\n\nexport function download(\n  sessionContext: SessionContext,\n  provision: (string | number | boolean)[],\n  declarations: Declaration[],\n): boolean {\n  if (\n    (provision.length !== 3 ||\n      typeof provision[1] !== \"string\" ||\n      typeof provision[2] !== \"string\") &&\n    (provision.length !== 4 ||\n      typeof provision[1] !== \"string\" ||\n      typeof provision[2] !== \"string\" ||\n      typeof provision[3] !== \"string\")\n  )\n    throw new Error(\"Invalid arguments\");\n\n  const alias = [\n    `FileType:${JSON.stringify(provision[1] || \"\")}`,\n    `FileName:${JSON.stringify(provision[2] || \"\")}`,\n    `TargetFileName:${JSON.stringify(provision[3] || \"\")}`,\n  ].join(\",\");\n\n  declarations.push({\n    path: Path.parse(`Downloads.[${alias}]`),\n    pathGet: 1,\n    pathSet: 1,\n    attrGet: null,\n    attrSet: null,\n    defer: true,\n  });\n\n  declarations.push({\n    path: Path.parse(`Downloads.[${alias}].Download`),\n    pathGet: 1,\n    pathSet: null,\n    attrGet: { value: 1 },\n    attrSet: { value: [sessionContext.timestamp] },\n    defer: true,\n  });\n\n  return true;\n}\n\nexport function instances(\n  sessionContext: SessionContext,\n  provision: (string | number | boolean)[],\n  declarations: Declaration[],\n  startRevision: number,\n  endRevision: number,\n): boolean {\n  if (provision.length !== 3 || typeof provision[1] !== \"string\")\n    throw new Error(\"Invalid arguments\");\n\n  let count = Number(provision[2]);\n\n  if (Number.isNaN(count)) throw new Error(\"Invalid arguments\");\n\n  const path = Path.parse(provision[1]);\n\n  if (provision[2][0] === \"+\" || provision[2][0] === \"-\") {\n    declarations.push({\n      path: path,\n      pathGet: 1,\n      pathSet: null,\n      attrGet: null,\n      attrSet: null,\n      defer: true,\n    });\n\n    if (endRevision === startRevision) return false;\n\n    const unpacked = device.unpack(\n      sessionContext.deviceData,\n      path,\n      startRevision + 1,\n    );\n    count = Math.max(0, unpacked.length + count);\n  }\n\n  declarations.push({\n    path: path,\n    pathGet: 1,\n    pathSet: count,\n    attrGet: null,\n    attrSet: null,\n    defer: true,\n  });\n\n  return true;\n}\n"
  },
  {
    "path": "lib/device.ts",
    "content": "import Expression, { Value } from \"./common/expression.ts\";\nimport Path from \"./common/path.ts\";\nimport {\n  DeviceData,\n  Declaration,\n  Clear,\n  AttributeTimestamps,\n  Attributes,\n} from \"./types.ts\";\n\nconst CHANGE_FLAGS = {\n  object: 2,\n  writable: 4,\n  value: 8,\n  notification: 16,\n  accessList: 32,\n};\n\nfunction parseBool(v): boolean {\n  v = \"\" + v;\n  if (v === \"true\" || v === \"TRUE\" || v === \"True\" || v === \"1\") return true;\n  else if (v === \"false\" || v === \"FALSE\" || v === \"False\" || v === \"0\")\n    return false;\n  else return null;\n}\n\nexport function sanitizeParameterValue(\n  parameterValue: [string | number | boolean, string],\n): [string | number | boolean, string] {\n  if (parameterValue[0] != null) {\n    switch (parameterValue[1]) {\n      case \"xsd:boolean\":\n        if (typeof parameterValue[0] !== \"boolean\") {\n          const b = parseBool(parameterValue[0]);\n          if (b == null) parameterValue[0] = \"\" + parameterValue[0];\n          else parameterValue[0] = b;\n        }\n        break;\n      case \"xsd:int\":\n      case \"xsd:unsignedInt\":\n        if (typeof parameterValue[0] !== \"number\") {\n          const i = parseInt(parameterValue[0] as string);\n          if (isNaN(i)) parameterValue[0] = \"\" + parameterValue[0];\n          else parameterValue[0] = i;\n        }\n        break;\n      case \"xsd:dateTime\":\n        if (typeof parameterValue[0] !== \"number\") {\n          // Don't use parseInt because it reads date string as a number\n          let i = +parameterValue[0];\n          if (isNaN(i)) {\n            i = Date.parse(parameterValue[0] as string);\n            if (isNaN(i)) parameterValue[0] = \"\" + parameterValue[0];\n            else parameterValue[0] = i;\n          } else {\n            parameterValue[0] = i;\n          }\n        }\n        break;\n      default:\n        parameterValue[0] = \"\" + parameterValue[0];\n        break;\n    }\n  }\n\n  return parameterValue;\n}\n\nexport function getAliasDeclarations(\n  path: Path,\n  timestamp: number,\n  attrGet = null,\n): Declaration[] {\n  const stripped = path.stripAlias();\n  let decs: Declaration[] = [\n    {\n      path: stripped,\n      pathGet: timestamp,\n      pathSet: null,\n      attrGet: attrGet,\n      attrSet: null,\n      defer: true,\n    },\n  ];\n\n  if (path.alias) {\n    for (const [i, alias] of path.segments.entries()) {\n      if (alias instanceof Expression) {\n        const parent = stripped.slice(0, i + 1);\n        for (const [p] of expressionToAlias(alias)) {\n          decs = decs.concat(\n            getAliasDeclarations(parent.concat(p), timestamp, {\n              value: timestamp,\n            }),\n          );\n        }\n      }\n    }\n  }\n\n  return decs;\n}\n\nexport function expressionToAlias(exp: Expression): [Path, Value][] {\n  if (exp instanceof Expression.Literal && exp.value === true) return [];\n  if (exp instanceof Expression.Binary) {\n    if (exp.operator === \"AND\")\n      return [...expressionToAlias(exp.left), ...expressionToAlias(exp.right)];\n    else if (exp.operator === \"=\") {\n      if (\n        exp.left instanceof Expression.Parameter &&\n        exp.right instanceof Expression.Literal\n      )\n        return [[exp.left.path, exp.right.value]];\n    }\n  }\n  throw new Error(\"Invalid alias expression\");\n}\n\nexport function unpack(\n  deviceData: DeviceData,\n  path: Path,\n  revision?: number,\n): Path[] {\n  let allMatches = [] as Path[];\n  if (!path.alias) {\n    for (const p of deviceData.paths.findCompat(path, false, true))\n      if (deviceData.attributes.has(p, revision)) allMatches.push(p);\n  } else {\n    const wildcardPath = path.stripAlias();\n\n    for (const p of deviceData.paths.findCompat(wildcardPath, false, true))\n      if (deviceData.attributes.has(p, revision)) allMatches.push(p);\n\n    for (let i = path.length - 1; i >= 0; --i) {\n      if (path.alias & (1 << i)) {\n        for (const [param, val] of expressionToAlias(\n          path.segments[i] as Expression,\n        )) {\n          const p = wildcardPath.slice(0, i + 1).concat(param);\n          const unpacked = unpack(deviceData, p, revision);\n          const filtered: Path[] = [];\n          for (const up of unpacked) {\n            const attributes = deviceData.attributes.get(up, revision);\n            if (\n              attributes &&\n              attributes.value &&\n              attributes.value[1] &&\n              sanitizeParameterValue([val, attributes.value[1][1]])[0] ===\n                attributes.value[1][0]\n            ) {\n              for (let m = 0; m < allMatches.length; ++m) {\n                let k;\n                const match = allMatches[m];\n                if (!match) continue;\n                for (k = i; k >= 0; --k)\n                  if (match.segments[k] !== up.segments[k]) break;\n\n                if (k < 0) {\n                  filtered.push(match);\n                  allMatches[m] = null;\n                }\n              }\n            }\n          }\n          allMatches = filtered;\n        }\n      }\n    }\n  }\n\n  allMatches.sort((p1, p2) => {\n    for (let i = 0; i < p1.length; ++i) {\n      const a = p1.segments[i] as string;\n      const b = p2.segments[i] as string;\n      if (a !== b) {\n        // Use numeric sorting for numbers\n        const ia = parseInt(a);\n        const ib = parseInt(b);\n\n        if (ia === +a && ib === +b) return ia - ib;\n        else if (a < b) return -1;\n        else return 1;\n      }\n    }\n    return 0;\n  });\n\n  return allMatches;\n}\n\nexport function clear(\n  deviceData: DeviceData,\n  path: Path,\n  timestamp: number,\n  attributes: AttributeTimestamps,\n  changeFlags = 0,\n): void {\n  const changeTrackers = {};\n\n  timestamp = timestamp || 0;\n\n  let descendantsTimestamp = timestamp;\n  if (attributes?.object) {\n    if (attributes.object > descendantsTimestamp)\n      descendantsTimestamp = attributes.object;\n    if (!(attributes.object <= attributes.value))\n      attributes.value = attributes.object;\n  }\n\n  for (const p of deviceData.paths.findCompat(\n    path,\n    true,\n    true,\n    descendantsTimestamp ? 99 : path.length,\n  )) {\n    const tracker = deviceData.trackers.get(p);\n    for (const k in tracker) changeTrackers[k] |= tracker[k];\n\n    const currentTimestamp = deviceData.timestamps.get(p);\n    if (currentTimestamp === undefined) continue;\n\n    if (\n      timestamp > currentTimestamp ||\n      (descendantsTimestamp > currentTimestamp && p.length > path.length)\n    ) {\n      deviceData.timestamps.delete(p);\n      deviceData.attributes.delete(p);\n      changeFlags |= 1;\n    } else if (attributes && p.length === path.length) {\n      const currentAttributes = deviceData.attributes.get(p);\n      if (currentAttributes) {\n        let newAttrs;\n        for (const attrName in attributes) {\n          if (\n            attrName in currentAttributes &&\n            attributes[attrName] > currentAttributes[attrName][0]\n          ) {\n            changeFlags |= CHANGE_FLAGS[attrName];\n            if (!newAttrs) {\n              newAttrs = Object.assign({}, currentAttributes);\n              deviceData.attributes.set(p, newAttrs);\n            }\n            delete newAttrs[attrName];\n          }\n        }\n      }\n    }\n  }\n\n  // Note: For performance, we're merging all changes together rather than\n  // mark changes based the exact parameters affected.\n  for (const k in changeTrackers)\n    if (changeTrackers[k] & changeFlags) deviceData.changes.add(k);\n}\n\nfunction compareEquality(a, b): boolean {\n  const t = typeof a;\n  if (\n    a === null ||\n    a === undefined ||\n    t === \"number\" ||\n    t === \"boolean\" ||\n    t === \"string\" ||\n    t === \"symbol\"\n  )\n    return a === b;\n\n  return JSON.stringify(a) === JSON.stringify(b);\n}\n\nexport function set(\n  deviceData: DeviceData,\n  pathStr: string,\n  timestamp: number,\n  attributes: Attributes,\n  toClear?: Clear[],\n): Clear[] {\n  const path = deviceData.paths.add(pathStr);\n\n  const currentTimestamp = deviceData.timestamps.get(path);\n\n  let currentAttributes;\n\n  if (path.wildcard) attributes = undefined;\n  else if (currentTimestamp)\n    currentAttributes = deviceData.attributes.get(path);\n\n  let changeFlags = 0;\n\n  if (attributes) {\n    if (\n      attributes.value &&\n      attributes.value[1] &&\n      attributes.value[0] >= (attributes.object ? attributes.object[0] : 0)\n    )\n      attributes.object = [attributes.value[0], 0];\n\n    if (\n      attributes.object &&\n      attributes.object[1] &&\n      attributes.object[0] >= (attributes.value ? attributes.value[0] : 0)\n    )\n      attributes.value = [attributes.object[0], null];\n\n    const newAttributes = Object.assign({}, currentAttributes, attributes);\n\n    if (currentAttributes) {\n      for (const attrName in attributes) {\n        timestamp = Math.max(timestamp, attributes[attrName][0]);\n        if (!(attrName in currentAttributes))\n          changeFlags |= CHANGE_FLAGS[attrName];\n        else if (attributes[attrName][0] <= currentAttributes[attrName][0])\n          newAttributes[attrName] = currentAttributes[attrName];\n        else if (\n          !compareEquality(\n            attributes[attrName][1],\n            currentAttributes[attrName][1],\n          )\n        )\n          changeFlags |= CHANGE_FLAGS[attrName];\n      }\n    } else {\n      changeFlags |= 1;\n    }\n\n    deviceData.attributes.set(path, newAttributes);\n\n    if (!(timestamp <= currentTimestamp)) {\n      deviceData.timestamps.set(path, timestamp);\n      if (path.length > 1) {\n        toClear = set(\n          deviceData,\n          path.slice(0, path.length - 1).toString(),\n          timestamp,\n          { object: [timestamp, 1] },\n          toClear,\n        );\n      }\n    }\n  } else if (!(timestamp <= currentTimestamp)) {\n    deviceData.timestamps.set(path, timestamp);\n\n    if (currentAttributes) {\n      deviceData.attributes.delete(path);\n      changeFlags |= 1;\n    } else if (path.wildcard) {\n      for (const p of deviceData.paths.findCompat(\n        path,\n        false,\n        true,\n        path.length,\n      )) {\n        if (timestamp > deviceData.timestamps.get(p)) {\n          toClear = toClear || [];\n          toClear.push([p, timestamp]);\n        }\n      }\n    }\n  }\n\n  if (changeFlags) {\n    if (changeFlags & 1) {\n      toClear = toClear || [];\n      toClear.push([path, timestamp, null, changeFlags]);\n    } else if (changeFlags & CHANGE_FLAGS.object) {\n      toClear = toClear || [];\n      toClear.push([path, 0, { object: attributes.object[0] }, changeFlags]);\n    } else {\n      for (const p of deviceData.paths.findCompat(\n        path,\n        true,\n        false,\n        path.length,\n      )) {\n        const tracker = deviceData.trackers.get(p);\n        for (const k in tracker)\n          if (tracker[k] & changeFlags) deviceData.changes.add(k);\n      }\n    }\n  }\n\n  return toClear;\n}\n\nexport function track(\n  deviceData: DeviceData,\n  pathStr: string,\n  marker: string,\n  attributes?: string[],\n): void {\n  const path = deviceData.paths.add(pathStr);\n  let f = 1;\n\n  if (attributes)\n    for (const attrName of attributes) f |= CHANGE_FLAGS[attrName];\n\n  let cur = deviceData.trackers.get(path);\n  if (!cur) {\n    cur = {};\n    deviceData.trackers.set(path, cur);\n  }\n\n  cur[marker] |= f;\n}\n\nexport function clearTrackers(\n  deviceData: DeviceData,\n  tracker: string | string[],\n): void {\n  if (Array.isArray(tracker)) {\n    for (const v of deviceData.trackers.values())\n      for (const t of tracker) delete v[t];\n    for (const t of tracker) deviceData.changes.delete(t);\n  } else {\n    for (const v of deviceData.trackers.values()) delete v[tracker];\n    deviceData.changes.delete(tracker);\n  }\n}\n"
  },
  {
    "path": "lib/extensions.ts",
    "content": "import { spawn, ChildProcess } from \"node:child_process\";\nimport * as crypto from \"node:crypto\";\nimport readline from \"node:readline\";\nimport * as config from \"./config.ts\";\nimport { Fault } from \"./types.ts\";\nimport { ROOT_DIR } from \"./config.ts\";\nimport * as logger from \"./logger.ts\";\n\nconst TIMEOUT = +config.get(\"EXT_TIMEOUT\");\n\nconst processes: { [script: string]: ChildProcess } = {};\nconst jobs = new Map();\n\nexport function run(args: string[]): Promise<{ fault: Fault; value: any }> {\n  return new Promise((resolve) => {\n    const scriptName = args[0];\n\n    const id = crypto.randomBytes(8).toString(\"hex\");\n    jobs.set(id, resolve);\n\n    if (!processes[scriptName]) {\n      const p = spawn(ROOT_DIR + \"/bin/genieacs-ext\", [scriptName], {\n        stdio: [\"ignore\", \"pipe\", \"pipe\", \"ipc\"],\n      });\n      processes[scriptName] = p;\n\n      p.on(\"error\", (err) => {\n        if (processes[scriptName] === p) {\n          if (jobs.delete(id)) {\n            resolve({\n              fault: { code: err.name, message: err.message },\n              value: null,\n            });\n          }\n\n          // eslint-disable-next-line @typescript-eslint/no-floating-promises\n          kill(processes[scriptName]);\n          delete processes[scriptName];\n        }\n      });\n\n      p.on(\"disconnect\", () => {\n        if (processes[scriptName] === p) delete processes[scriptName];\n      });\n\n      p.on(\"message\", (message) => {\n        const func = jobs.get(message[0]);\n        if (func) {\n          jobs.delete(message[0]);\n          // Wait for any disconnect even to fire\n          setTimeout(() => {\n            func({ fault: message[1], value: message[2] });\n          });\n        }\n      });\n\n      const rlstdout = readline.createInterface(p.stdout);\n      rlstdout.on(\"line\", (line) => {\n        logger.info({ message: `Ext ${scriptName}(${p.pid}): ${line}` });\n      });\n\n      const rlstderr = readline.createInterface(p.stderr);\n      rlstderr.on(\"line\", (line) => {\n        logger.warn({ message: `Ext ${scriptName}(${p.pid}): ${line}` });\n      });\n    }\n\n    setTimeout(() => {\n      if (jobs.delete(id)) {\n        resolve({\n          fault: { code: \"timeout\", message: \"Extension timed out\" },\n          value: null,\n        });\n      }\n    }, TIMEOUT);\n\n    if (!processes[scriptName].connected) return false;\n\n    return processes[scriptName].send([id, args.slice(1)]);\n  });\n}\n\nfunction kill(process: ChildProcess): Promise<void> {\n  return new Promise((resolve) => {\n    const timeToKill = Date.now() + 5000;\n\n    process.kill();\n\n    const t = setInterval(() => {\n      if (!process.connected) {\n        clearInterval(t);\n        resolve();\n      } else if (Date.now() > timeToKill) {\n        process.kill(\"SIGKILL\");\n        clearInterval(t);\n        resolve();\n      }\n    }, 100);\n  });\n}\n\nexport async function killAll(): Promise<void> {\n  await Promise.all(\n    Object.entries(processes).map(([k, p]) => {\n      delete processes[k];\n      return kill(p);\n    }),\n  );\n}\n"
  },
  {
    "path": "lib/forwarded.ts",
    "content": "import { IncomingMessage } from \"node:http\";\nimport { TLSSocket } from \"node:tls\";\nimport { parseCIDR, parse, IPv6, IPv4 } from \"ipaddr.js\";\nimport * as config from \"./config.ts\";\nimport { getSocketEndpoints } from \"./server.ts\";\n\ninterface RequestOrigin {\n  localAddress: string;\n  localPort: number;\n  remoteAddress: string;\n  remotePort: number;\n  host: string;\n  encrypted: boolean;\n}\n\nconst FORWARDED_HEADER = \"\" + config.get(\"FORWARDED_HEADER\");\nconst cache = new WeakMap<IncomingMessage, RequestOrigin>();\nconst cidrs: [IPv4 | IPv6, number][] = [];\n\nfor (const str of FORWARDED_HEADER.split(\",\").map((s) => s.trim())) {\n  try {\n    cidrs.push(parseCIDR(str));\n  } catch {\n    // Not a valid CIDR format, try parsing as IP\n    try {\n      const ip = parse(str);\n      cidrs.push([ip, ip.toByteArray().length * 8]);\n    } catch {\n      // Not a valid IP either, ignore\n    }\n  }\n}\n\nfunction parseForwardedHeader(str: string): { [name: string]: string } {\n  str = str.toLowerCase();\n  const res: { [name: string]: string } = {};\n  let keyIdx = 0;\n  let valueIdx = -1;\n  let key: string;\n  for (let i = 0; i < str.length; ++i) {\n    const char = str.charCodeAt(i);\n    if (char === 61 /* = */) {\n      if (keyIdx >= 0) {\n        key = str.slice(keyIdx, i).trim();\n        keyIdx = -1;\n        valueIdx = i + 1;\n      }\n    } else if (char === 59 /* ; */) {\n      if (valueIdx >= 0) res[key] = str.slice(valueIdx, i).trim();\n      valueIdx = -1;\n      keyIdx = i + 1;\n    } else if (char === 44 /* , */) {\n      if (valueIdx >= 0) res[key] = str.slice(valueIdx, i).trim();\n      return res;\n    } else if (char === 34 /* \" */) {\n      if (valueIdx >= 0) {\n        const quoteIdx = i;\n        if (!str.slice(valueIdx, quoteIdx).trim()) {\n          for (i = i + 1; i < str.length; ++i) {\n            const c = str.charCodeAt(i);\n            if (c === 92 /* \\ */) ++i;\n            if (c === 34 /* \" */) {\n              res[key] = JSON.parse(str.slice(quoteIdx, i + 1).trim());\n              valueIdx = -1;\n              keyIdx = i + 1;\n              break;\n            }\n          }\n        }\n      }\n    }\n  }\n\n  if (valueIdx >= 0) res[key] = str.slice(valueIdx).trim();\n\n  return res;\n}\n\nexport function getRequestOrigin(request: IncomingMessage): RequestOrigin {\n  let origin = cache.get(request);\n  if (!origin) {\n    const soc = request.socket;\n    const socketEndpoints = getSocketEndpoints(soc);\n    origin = {\n      localAddress: socketEndpoints.localAddress,\n      localPort: socketEndpoints.localPort,\n      remoteAddress: socketEndpoints.remoteAddress,\n      remotePort: socketEndpoints.remotePort,\n      host: request.headers[\"host\"],\n      encrypted: !!(request.socket as TLSSocket).encrypted,\n    };\n\n    const header = request.headers[\"forwarded\"];\n    if (header) {\n      const ip = parse(socketEndpoints.remoteAddress);\n      if (\n        cidrs.some((cidr) => ip.kind() === cidr[0].kind() && ip.match(cidr))\n      ) {\n        const parsed = parseForwardedHeader(header);\n\n        if (parsed[\"proto\"] === \"https\") {\n          origin.encrypted = true;\n          origin.localPort = 443;\n        } else if (parsed[\"proto\"] === \"http\") {\n          origin.encrypted = false;\n          origin.localPort = 80;\n        }\n\n        if (parsed[\"host\"]) {\n          origin.host = parsed[\"host\"];\n          const [, port] = parsed[\"host\"].split(\":\", 2);\n          origin.localPort = +port || origin.localPort;\n        }\n\n        if (parsed[\"for\"]) {\n          if (parsed[\"for\"].startsWith(\"[\")) {\n            const i = parsed[\"for\"].lastIndexOf(\"]\");\n            if (i >= 0) {\n              origin.remoteAddress = parsed[\"for\"].slice(1, i);\n              origin.remotePort =\n                parseInt(parsed[\"for\"].slice(i + 2)) || origin.remotePort;\n            }\n          } else {\n            const i = parsed[\"for\"].lastIndexOf(\":\");\n            if (i >= 0) {\n              origin.remoteAddress = parsed[\"for\"].slice(0, i);\n              origin.remotePort =\n                parseInt(parsed[\"for\"].slice(i + 1)) || origin.remotePort;\n            } else {\n              origin.remoteAddress = parsed[\"for\"];\n            }\n          }\n        }\n\n        if (parsed[\"by\"]) {\n          if (parsed[\"by\"].startsWith(\"[\")) {\n            const i = parsed[\"by\"].lastIndexOf(\"]\");\n            if (i >= 0) {\n              origin.localAddress = parsed[\"by\"].slice(1, i);\n              origin.localPort =\n                parseInt(parsed[\"by\"].slice(i + 2)) || origin.localPort;\n            }\n          } else {\n            const i = parsed[\"by\"].lastIndexOf(\":\");\n            if (i >= 0) {\n              origin.localAddress = parsed[\"by\"].slice(0, i);\n              origin.localPort =\n                parseInt(parsed[\"by\"].slice(i + 1)) || origin.localPort;\n            } else {\n              origin.localAddress = parsed[\"by\"];\n            }\n          }\n        }\n      }\n    }\n\n    cache.set(request, origin);\n  }\n  return origin;\n}\n"
  },
  {
    "path": "lib/fs.ts",
    "content": "import * as url from \"node:url\";\nimport { IncomingMessage, ServerResponse } from \"node:http\";\nimport { PassThrough, pipeline, Readable } from \"node:stream\";\nimport { createHash } from \"node:crypto\";\nimport { filesBucket, collections } from \"./db/db.ts\";\nimport * as logger from \"./logger.ts\";\nimport { getRequestOrigin } from \"./forwarded.ts\";\nimport memoize from \"./common/memoize.ts\";\n\nconst getFile = memoize(\n  async (\n    etag: string,\n    size: number,\n    filename: string,\n  ): Promise<Iterable<Buffer>> => {\n    const chunks: Buffer[] = [];\n    // Using for-await over the download stream can throw ERR_STREAM_PREMATURE_CLOSE\n    // for very small files. Possibly a bug in MongoDB driver or Nodejs itself.\n    // Using a PassThrough stream to avoid this.\n    const downloadStream = pipeline(\n      filesBucket.openDownloadStreamByName(filename),\n      new PassThrough(),\n      (err) => {\n        if (err) throw err;\n      },\n    );\n    for await (const chunk of downloadStream) chunks.push(chunk);\n    // Node 12-14 don't throw error when stream is closed prematurely.\n    // However, we don't need to check for that since we're checking file size.\n    if (size !== chunks.reduce((a, b) => a + b.length, 0))\n      throw new Error(\"File size mismatch\");\n    return chunks;\n  },\n);\n\nasync function* partialContent(\n  chunks: Iterable<Buffer>,\n  start: number,\n  end: number,\n): AsyncIterable<Buffer> {\n  let bytesToSkip = start;\n  let bytesToRead = end - start;\n\n  for (let chunk of chunks) {\n    if (bytesToRead <= 0) return;\n    if (bytesToSkip >= chunk.length) {\n      bytesToSkip -= chunk.length;\n      continue;\n    }\n    chunk = chunk.subarray(bytesToSkip, bytesToSkip + bytesToRead);\n    bytesToRead -= chunk.length;\n    bytesToSkip = 0;\n    yield chunk;\n  }\n}\n\nfunction generateETag(file: {\n  _id: string;\n  uploadDate: Date;\n  length: number;\n}): string {\n  const hash = createHash(\"md5\");\n  hash.update(`${file._id}-${file.uploadDate.getTime()}-${file.length}`);\n  return hash.digest(\"hex\");\n}\n\nfunction matchEtag(etag: string, header: string): boolean {\n  for (let t of header.split(\",\")) {\n    t = t.trim();\n    if (t.startsWith(\"W/\")) t = t.substring(2);\n    try {\n      t = JSON.parse(t);\n    } catch {\n      // Ignore\n    }\n    if (t === \"*\") return true;\n    if (etag === t) return true;\n  }\n  return false;\n}\n\nexport async function listener(\n  request: IncomingMessage,\n  response: ServerResponse,\n): Promise<void> {\n  if (request.method !== \"GET\" && request.method !== \"HEAD\") {\n    response.writeHead(405, { Allow: \"GET, HEAD\" });\n    response.end(\"405 Method Not Allowed\");\n    return;\n  }\n\n  const urlParts = url.parse(request.url, true);\n  const filename = decodeURIComponent(urlParts.pathname.substring(1));\n\n  const log = {\n    message: \"Fetch file\",\n    filename: filename,\n    remoteAddress: getRequestOrigin(request).remoteAddress,\n    method: request.method,\n  };\n\n  const file = await collections.files.findOne({ _id: filename });\n\n  if (!file) {\n    response.writeHead(404);\n    response.end();\n    log.message += \" not found\";\n    logger.accessError(log);\n    return;\n  }\n\n  logger.accessInfo(log);\n\n  const etag = generateETag(file);\n  const lastModified = file[\"uploadDate\"];\n  lastModified.setMilliseconds(0);\n\n  let status = 200;\n  let start = 0;\n  let end = file.length;\n\n  if (request.headers[\"if-match\"])\n    if (!matchEtag(etag, request.headers[\"if-match\"])) status = 412;\n\n  if (request.headers[\"if-unmodified-since\"]) {\n    const d = new Date(request.headers[\"if-unmodified-since\"]);\n    if (lastModified > d) status = 412;\n  }\n\n  if (request.headers[\"if-none-match\"]) {\n    if (matchEtag(etag, request.headers[\"if-none-match\"])) status = 304;\n  } else if (request.headers[\"if-modified-since\"]) {\n    const d = new Date(request.headers[\"if-modified-since\"]);\n    if (lastModified <= d) status = 304;\n  }\n\n  if (request.headers.range && status === 200) {\n    const match = request.headers.range.match(/^bytes=(\\d*)-(\\d*)$/);\n    status = 416;\n    if (match && (match[1] || match[2])) {\n      if (match[2]) end = parseInt(match[2]) + 1;\n      if (match[1]) start = parseInt(match[1]);\n      else start = file.length - parseInt(match[2]);\n      if (start < end && end <= file.length) status = 206;\n    }\n\n    if (request.headers[\"if-range\"]) {\n      const h = request.headers[\"if-range\"] as string;\n      const d = new Date(h);\n      if (!matchEtag(etag, h) && !(lastModified <= d)) {\n        status = 200;\n        start = 0;\n        end = file.length;\n      }\n    }\n  }\n\n  if (status === 412) {\n    response.writeHead(412);\n    response.end();\n    return;\n  }\n\n  if (status === 304) {\n    response.writeHead(304, {\n      ETag: etag,\n      \"Last-Modified\": lastModified.toUTCString(),\n    });\n    response.end();\n    return;\n  }\n\n  if (status === 416) {\n    response.writeHead(416, {\n      \"Content-Range\": `bytes */${file.length}`,\n      \"Content-Length\": \"0\",\n    });\n    response.end();\n    return;\n  }\n\n  response.writeHead(status, {\n    \"Content-Type\": \"application/octet-stream\",\n    \"Content-Length\": end - start,\n    \"Accept-Ranges\": \"bytes\",\n    ETag: etag,\n    \"Last-Modified\": lastModified.toUTCString(),\n    ...(status === 206 && {\n      \"Content-Range\": `bytes ${start}-${end - 1}/${file.length}`,\n    }),\n  });\n\n  if (request.method === \"HEAD\") {\n    response.end();\n    return;\n  }\n\n  const chunks = await getFile(etag, file.length, filename);\n\n  pipeline(Readable.from(partialContent(chunks, start, end)), response, () => {\n    // Ignore errors resulting from client disconnecting\n  });\n}\n"
  },
  {
    "path": "lib/gpn-heuristic.ts",
    "content": "import Path from \"./common/path.ts\";\n\nconst WILDCARD_MULTIPLIER = 2;\nconst UNDISCOVERED_DEPTH = 7;\n\n// Simple heuristic to estimate GPN count given a set of patterns to be\n// discovered. Used to decide whether to use nextLevel = false in GPN.\n// gpnPatterns is [pattern, flags]\n// pattern is a path (array)\n// flags is an int where its bits mark the segments in the pattern that\n// need refreshing. Leading 0s indicate that the pattern up to that\n// point has been discovered.\nexport function estimateGpnCount(\n  gpnPatterns: [Path, number][],\n  depth = 0,\n): number {\n  const children: { [segment: string]: [Path, number][] } = {};\n  const wildcardChildren: [Path, number][] = [];\n  let wildcardDiscovered = false;\n  let gpnCount = 0;\n\n  for (const pattern of gpnPatterns) {\n    const path = pattern[0];\n    const flags = pattern[1] >> depth;\n\n    const k = path.segments[depth] as string;\n\n    if (!k) {\n      if (flags & 1) gpnCount = 1;\n      continue;\n    }\n\n    if (flags & 1) {\n      gpnCount = 1;\n\n      if (depth > UNDISCOVERED_DEPTH) continue;\n    } else if (k === \"*\") {\n      wildcardDiscovered = true;\n    }\n\n    if (k === \"*\") {\n      wildcardChildren.push(pattern);\n    } else {\n      children[k] = children[k] || [];\n      children[k].push(pattern);\n    }\n  }\n\n  let wildcardGpnCount = 0;\n  if (!wildcardDiscovered && wildcardChildren.length) {\n    wildcardGpnCount +=\n      estimateGpnCount(wildcardChildren, depth + 1) * WILDCARD_MULTIPLIER;\n  }\n\n  for (const k of Object.keys(children)) {\n    const c = estimateGpnCount(children[k].concat(wildcardChildren), depth + 1);\n    wildcardGpnCount -= c;\n    gpnCount += c;\n  }\n\n  gpnCount += Math.max(0, wildcardGpnCount);\n\n  return gpnCount;\n}\n"
  },
  {
    "path": "lib/init.ts",
    "content": "import { getRevision, getUiConfig, getUsers } from \"./ui/local-cache.ts\";\nimport { generateSalt, hashPassword } from \"./auth.ts\";\nimport { collections } from \"./db/db.ts\";\nimport {\n  putConfig,\n  putPermission,\n  putPreset,\n  putProvision,\n  putUser,\n  putView,\n} from \"./ui/db.ts\";\nimport { del } from \"./cache.ts\";\nimport BOOTSTRAP_SCRIPT from \"../seed/bootstrap.js\" with { type: \"text\" };\nimport DEFAULT_SCRIPT from \"../seed/default.js\" with { type: \"text\" };\nimport INFORM_SCRIPT from \"../seed/inform.js\" with { type: \"text\" };\nimport OVERVIEW_PAGE from \"../seed/overview-page.jsx\" with { type: \"text\" };\nimport PIE_CHART from \"../seed/pie-chart.jsx\" with { type: \"text\" };\nimport DEVICE_PAGE from \"../seed/device-page.jsx\" with { type: \"text\" };\nimport DEVICE_PAGE_TR098 from \"../seed/device-page-tr098.jsx\" with { type: \"text\" };\nimport DEVICE_PAGE_TR181 from \"../seed/device-page-tr181.jsx\" with { type: \"text\" };\nimport PARAMETER from \"../seed/parameter.jsx\" with { type: \"text\" };\nimport SUMMON_BUTTON from \"../seed/summon-button.jsx\" with { type: \"text\" };\nimport ICON from \"../seed/icon.jsx\" with { type: \"text\" };\nimport DATAMODEL_EXPLORER from \"../seed/datamodel-explorer.jsx\" with { type: \"text\" };\nimport INSTANCE_TABLE from \"../seed/instance-table.jsx\" with { type: \"text\" };\nimport TAGS from \"../seed/tags.jsx\" with { type: \"text\" };\n\ninterface Status {\n  users: boolean;\n  presets: boolean;\n  filters: boolean;\n  device: boolean;\n  index: boolean;\n  overview: boolean;\n}\n\nexport async function getStatus(): Promise<Status> {\n  const [configSnapshot, presetCount] = await Promise.all([\n    getRevision(),\n    collections.presets.countDocuments(),\n  ]);\n  const users = getUsers(configSnapshot);\n  const ui = getUiConfig(configSnapshot);\n\n  const status = {\n    users: !Object.keys(users).length,\n    presets: !presetCount,\n    filters: true,\n    device: true,\n    index: true,\n    overview: true,\n  };\n\n  for (const k of Object.keys(ui)) {\n    if (k.startsWith(\"filters.\")) status.filters = false;\n    if (k === \"device\" || k.startsWith(\"device.\")) status.device = false;\n    if (k.startsWith(\"index.\")) status.index = false;\n    if (k === \"overview\" || k.startsWith(\"overview.\")) status.overview = false;\n  }\n\n  return status;\n}\n\nexport async function seed(options: Record<string, boolean>): Promise<void> {\n  const resources = {};\n  const proms = [];\n\n  if (options.users) {\n    resources[\"permissions\"] = [\n      { role: \"admin\", resource: \"devices\", access: 3, validate: \"true\" },\n      { role: \"admin\", resource: \"faults\", access: 3, validate: \"true\" },\n      { role: \"admin\", resource: \"files\", access: 3, validate: \"true\" },\n      { role: \"admin\", resource: \"presets\", access: 3, validate: \"true\" },\n      { role: \"admin\", resource: \"provisions\", access: 3, validate: \"true\" },\n      { role: \"admin\", resource: \"config\", access: 3, validate: \"true\" },\n      { role: \"admin\", resource: \"permissions\", access: 3, validate: \"true\" },\n      { role: \"admin\", resource: \"users\", access: 3, validate: \"true\" },\n      {\n        role: \"admin\",\n        resource: \"virtualParameters\",\n        access: 3,\n        validate: \"true\",\n      },\n      {\n        role: \"admin\",\n        resource: \"views\",\n        access: 3,\n        validate: \"true\",\n      },\n    ];\n\n    resources[\"users\"] = [\n      { username: \"admin\", password: \"admin\", roles: [\"admin\"] },\n    ];\n  }\n\n  if (options.filters) {\n    resources[\"config\"] = (resources[\"config\"] || []).concat([\n      { _id: \"ui.filters.0.label\", value: \"'Serial number'\" },\n      { _id: \"ui.filters.0.parameter\", value: \"DeviceID.SerialNumber\" },\n      { _id: \"ui.filters.0.type\", value: \"'string'\" },\n      { _id: \"ui.filters.1.label\", value: \"'Product class'\" },\n      { _id: \"ui.filters.1.parameter\", value: \"DeviceID.ProductClass\" },\n      { _id: \"ui.filters.1.type\", value: \"'string'\" },\n      { _id: \"ui.filters.2.label\", value: \"'Tag'\" },\n      { _id: \"ui.filters.2.type\", value: \"'tag'\" },\n    ]);\n  }\n\n  if (options.device) {\n    resources[\"config\"] = (resources[\"config\"] || []).concat([\n      { _id: \"ui.device\", value: \"'device-page'\" },\n    ]);\n    resources[\"views\"] = (resources[\"views\"] || []).concat([\n      { _id: \"device-page\", script: DEVICE_PAGE },\n      { _id: \"device-page-tr098\", script: DEVICE_PAGE_TR098 },\n      { _id: \"device-page-tr181\", script: DEVICE_PAGE_TR181 },\n      { _id: \"parameter\", script: PARAMETER },\n      { _id: \"summon-button\", script: SUMMON_BUTTON },\n      { _id: \"icon\", script: ICON },\n      { _id: \"datamodel-explorer\", script: DATAMODEL_EXPLORER },\n      { _id: \"instance-table\", script: INSTANCE_TABLE },\n      { _id: \"tags\", script: TAGS },\n    ]);\n  }\n\n  if (options.index) {\n    resources[\"config\"] = (resources[\"config\"] || []).concat([\n      { _id: \"ui.index.0.type\", value: \"'device-link'\" },\n      { _id: \"ui.index.0.label\", value: \"'Serial number'\" },\n      { _id: \"ui.index.0.parameter\", value: \"DeviceID.SerialNumber\" },\n      { _id: \"ui.index.0.components.0.type\", value: \"'parameter'\" },\n      { _id: \"ui.index.1.label\", value: \"'Product class'\" },\n      { _id: \"ui.index.1.parameter\", value: \"DeviceID.ProductClass\" },\n      { _id: \"ui.index.2.label\", value: \"'Software version'\" },\n      {\n        _id: \"ui.index.2.parameter\",\n        value: \"InternetGatewayDevice.DeviceInfo.SoftwareVersion\",\n      },\n      { _id: \"ui.index.3.label\", value: \"'IP'\" },\n      {\n        _id: \"ui.index.3.parameter\",\n        value:\n          \"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress\",\n      },\n      { _id: \"ui.index.4.label\", value: \"'SSID'\" },\n      {\n        _id: \"ui.index.4.parameter\",\n        value: \"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID\",\n      },\n      { _id: \"ui.index.5.type\", value: \"'container'\" },\n      { _id: \"ui.index.5.label\", value: \"'Last inform'\" },\n      { _id: \"ui.index.5.element\", value: \"'span.inform'\" },\n      { _id: \"ui.index.5.parameter\", value: \"DATE_STRING(Events.Inform)\" },\n      { _id: \"ui.index.5.components.0.type\", value: \"'parameter'\" },\n      { _id: \"ui.index.5.components.1.chart\", value: \"'online'\" },\n      { _id: \"ui.index.5.components.1.type\", value: \"'overview-dot'\" },\n      { _id: \"ui.index.6.type\", value: \"'tags'\" },\n      { _id: \"ui.index.6.label\", value: \"'Tags'\" },\n      { _id: \"ui.index.6.parameter\", value: \"Tags\" },\n      { _id: \"ui.index.6.unsortable\", value: \"true\" },\n      { _id: \"ui.index.6.writable\", value: \"false\" },\n    ]);\n  }\n\n  if (options.overview) {\n    resources[\"config\"] = (resources[\"config\"] || []).concat([\n      { _id: \"ui.overview\", value: \"'overview-page'\" },\n    ]);\n    resources[\"views\"] = (resources[\"views\"] || []).concat([\n      { _id: \"overview-page\", script: OVERVIEW_PAGE },\n      { _id: \"pie-chart\", script: PIE_CHART },\n    ]);\n  }\n\n  if (options.presets) {\n    resources[\"presets\"] = [\n      {\n        _id: \"bootstrap\",\n        weight: 0,\n        channel: \"bootstrap\",\n        events: \"0 BOOTSTRAP\",\n        provision: \"bootstrap\",\n      },\n      { _id: \"default\", weight: 0, channel: \"default\", provision: \"default\" },\n      { _id: \"inform\", weight: 0, channel: \"inform\", provision: \"inform\" },\n    ];\n\n    resources[\"provisions\"] = [\n      { _id: \"bootstrap\", script: BOOTSTRAP_SCRIPT },\n      { _id: \"default\", script: DEFAULT_SCRIPT },\n      { _id: \"inform\", script: INFORM_SCRIPT },\n    ];\n  }\n\n  if (resources[\"permissions\"]) {\n    for (const p of resources[\"permissions\"]) {\n      p[\"_id\"] = `${p[\"role\"]}:${p[\"resource\"]}:${p[\"access\"]}`;\n      proms.push(putPermission(p[\"_id\"], p));\n    }\n  }\n\n  if (resources[\"users\"]) {\n    for (const u of resources[\"users\"]) {\n      u[\"salt\"] = await generateSalt(64);\n      u[\"password\"] = await hashPassword(u[\"password\"], u[\"salt\"]);\n      u[\"roles\"] = (u[\"roles\"] || []).join(\",\");\n      u[\"_id\"] = u[\"username\"];\n      delete u[\"username\"];\n      proms.push(putUser(u[\"_id\"], u));\n    }\n  }\n\n  if (resources[\"provisions\"]) {\n    for (const p of resources[\"provisions\"])\n      proms.push(putProvision(p[\"_id\"], p));\n  }\n\n  if (resources[\"presets\"])\n    for (const p of resources[\"presets\"]) proms.push(putPreset(p[\"_id\"], p));\n\n  if (resources[\"views\"])\n    for (const v of resources[\"views\"]) proms.push(putView(v[\"_id\"], v));\n\n  if (resources[\"config\"])\n    for (const c of resources[\"config\"]) proms.push(putConfig(c[\"_id\"], c));\n\n  await proms;\n  await Promise.all([del(\"ui-local-cache-hash\"), del(\"cwmp-local-cache-hash\")]);\n}\n"
  },
  {
    "path": "lib/instance-set.ts",
    "content": "interface Instance {\n  [name: string]: string;\n}\n\nexport default class InstanceSet {\n  declare private set: Set<Instance>;\n\n  public constructor() {\n    this.set = new Set();\n  }\n\n  public add(instance: Instance): void {\n    this.set.add(instance);\n  }\n\n  public delete(instance: Instance): void {\n    this.set.delete(instance);\n  }\n\n  public superset(instance: Instance): Instance[] {\n    const res = [];\n    for (const inst of this.set) {\n      let match = true;\n      for (const k in instance) {\n        if (inst[k] !== instance[k]) {\n          match = false;\n          break;\n        }\n      }\n\n      if (match) res.push(inst);\n    }\n\n    res.sort((a, b) => {\n      const keysA = Object.keys(a);\n      const keysB = Object.keys(b);\n\n      if (keysA.length !== keysB.length) return keysB.length - keysA.length;\n\n      keysA.sort();\n      keysB.sort();\n\n      for (let i = 0; i < keysA.length; ++i) {\n        if (keysA[i] > keysB[i]) return 1;\n        else if (keysA[i] < keysB[i]) return -1;\n        else if (a[keysA[i]] > b[keysB[i]]) return 1;\n        else if (a[keysA[i]] < b[keysB[i]]) return -1;\n      }\n\n      return 0;\n    });\n\n    return res;\n  }\n\n  public subset(instance: Instance): Instance[] {\n    const res = [];\n\n    for (const inst of this.set) {\n      let match = true;\n      for (const k in inst) {\n        if (inst[k] !== instance[k]) {\n          match = false;\n          break;\n        }\n      }\n\n      if (match) res.push(inst);\n    }\n\n    res.sort((a, b) => {\n      const keysA = Object.keys(a);\n      const keysB = Object.keys(b);\n\n      if (keysA.length !== keysB.length) return keysA.length - keysB.length;\n\n      keysA.sort();\n      keysB.sort();\n\n      for (let i = 0; i < keysA.length; ++i) {\n        if (keysA[i] > keysB[i]) return 1;\n        else if (keysA[i] < keysB[i]) return -1;\n        else if (a[keysA[i]] > b[keysB[i]]) return 1;\n        else if (a[keysA[i]] < b[keysB[i]]) return -1;\n      }\n\n      return 0;\n    });\n\n    return res;\n  }\n\n  public [Symbol.iterator](): IterableIterator<Instance> {\n    return this.set.values();\n  }\n\n  public forEach(callback: (instance: Instance) => void): void {\n    this.set.forEach(callback);\n  }\n\n  public values(): IterableIterator<Instance> {\n    return this.set.values();\n  }\n\n  public clear(): void {\n    this.set.clear();\n  }\n\n  public get size(): number {\n    return this.set.size;\n  }\n}\n"
  },
  {
    "path": "lib/local-cache.ts",
    "content": "import { setTimeoutPromise } from \"./util.ts\";\nimport { get, set } from \"./cache.ts\";\nimport { acquireLock, releaseLock } from \"./lock.ts\";\n\nconst REFRESH = 5000;\nconst EVICT_TIMEOUT = 120000;\n\nexport class LocalCache<T> {\n  private nextRefresh = 1;\n  private currentRevision: string = null;\n  private snapshots: Map<string, T> = new Map();\n\n  constructor(\n    private cacheKey: string,\n    private callback: () => Promise<[string, T]>,\n  ) {}\n\n  async getRevision(): Promise<string> {\n    if (Date.now() > this.nextRefresh) await this.refresh();\n    return this.currentRevision;\n  }\n\n  hasRevision(revision: string): boolean {\n    return this.snapshots.has(revision);\n  }\n\n  get(revision: string): T {\n    const snapshot = this.snapshots.get(revision);\n    if (!snapshot) throw new Error(\"Cache snapshot does not exist\");\n    return snapshot;\n  }\n\n  async refresh(): Promise<void> {\n    if (!this.nextRefresh) {\n      await setTimeoutPromise(20);\n      await this.refresh();\n      return;\n    }\n\n    const now = Date.now();\n    if (now < this.nextRefresh) return;\n    this.nextRefresh = 0;\n\n    const dbHash = await get(this.cacheKey);\n\n    if (this.currentRevision && dbHash === this.currentRevision) {\n      this.nextRefresh = now + (REFRESH - (now % REFRESH));\n      return;\n    }\n\n    const lockToken = await acquireLock(this.cacheKey, 5000);\n\n    const [hash, snapshot] = await this.callback();\n\n    if (this.currentRevision) {\n      const r = this.currentRevision;\n      const s = this.snapshots.get(r);\n      setTimeout(() => {\n        if (this.snapshots.get(r) === s) this.snapshots.delete(r);\n      }, EVICT_TIMEOUT).unref();\n    }\n\n    this.currentRevision = hash;\n    this.snapshots.set(hash, snapshot);\n\n    if (lockToken) {\n      if (hash !== dbHash) await set(this.cacheKey, hash, 300);\n      await releaseLock(this.cacheKey, lockToken);\n    }\n\n    this.nextRefresh = now + (REFRESH - (now % REFRESH));\n  }\n}\n"
  },
  {
    "path": "lib/lock.ts",
    "content": "import { collections } from \"./db/db.ts\";\n\nconst CLOCK_SKEW_TOLERANCE = 30000;\n\nexport async function acquireLock(\n  lockName: string,\n  ttl: number,\n  timeout = 0,\n  token = Math.random().toString(36).slice(2),\n): Promise<string> {\n  try {\n    const now = Date.now();\n    const r = await collections.locks.findOneAndUpdate(\n      { _id: lockName, value: token },\n      {\n        $set: {\n          expire: new Date(now + ttl + CLOCK_SKEW_TOLERANCE),\n        },\n        $currentDate: { timestamp: true },\n      },\n      { upsert: true, returnDocument: \"after\" },\n    );\n    if (Math.abs(r.value.timestamp.getTime() - now) > CLOCK_SKEW_TOLERANCE)\n      throw new Error(\"Database clock skew too great\");\n  } catch (err) {\n    if (err.code !== 11000) throw err;\n    if (!(timeout > 0)) return null;\n    const w = 50 + Math.random() * 50;\n    await new Promise((resolve) => setTimeout(resolve, w));\n    return acquireLock(lockName, ttl, timeout - w, token);\n  }\n\n  return token;\n}\n\nexport async function releaseLock(\n  lockName: string,\n  token: string,\n): Promise<void> {\n  const res = await collections.locks.deleteOne({\n    _id: lockName,\n    value: token,\n  });\n  if (res.deletedCount !== 1) throw new Error(\"Lock expired\");\n}\n\nexport async function getToken(lockName: string): Promise<string> {\n  const res = await collections.locks.findOne({ _id: lockName });\n  return res?.value;\n}\n"
  },
  {
    "path": "lib/logger.ts",
    "content": "import * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as config from \"./config.ts\";\nimport { getRequestOrigin } from \"./forwarded.ts\";\nimport {\n  SessionContext,\n  AcsRequest,\n  CpeRequest,\n  CpeFault,\n  InformRequest,\n  Fault,\n} from \"./types.ts\";\n\nconst REOPEN_EVERY = 60000;\n\nconst LOG_FORMAT = config.get(\"LOG_FORMAT\");\nconst ACCESS_LOG_FORMAT = config.get(\"ACCESS_LOG_FORMAT\") || LOG_FORMAT;\n\nconst defaultMeta: { [name: string]: any } = {};\n\nlet LOG_SYSTEMD = false;\nlet ACCESS_LOG_SYSTEMD = false;\n\nlet LOG_FILE, ACCESS_LOG_FILE;\n\ndeclare global {\n  /* eslint-disable-next-line @typescript-eslint/no-namespace */\n  namespace NodeJS {\n    export interface WritableStream {\n      fd?: number;\n    }\n  }\n}\n\ndeclare module \"fs\" {\n  interface WriteStream {\n    fd?: number;\n  }\n}\n\nlet logStream = fs.createWriteStream(null, { fd: process.stderr.fd });\nlet logStat = fs.fstatSync(logStream.fd);\nlet accessLogStream = fs.createWriteStream(null, { fd: process.stdout.fd });\nlet accessLogStat = fs.fstatSync(accessLogStream.fd);\n\n// Reopen if original files have been moved (e.g. logrotate)\nfunction reopen(): void {\n  let counter = 1;\n\n  if (LOG_FILE) {\n    ++counter;\n    fs.stat(LOG_FILE, (err, stat) => {\n      if (err && !err.message.startsWith(\"ENOENT:\")) throw err;\n\n      if (!(stat && stat.dev === logStat.dev && stat.ino === logStat.ino)) {\n        logStream.end();\n        logStream = fs.createWriteStream(null, {\n          fd: fs.openSync(LOG_FILE, \"a\"),\n        });\n        logStat = fs.fstatSync(logStream.fd);\n      }\n\n      if (--counter === 0)\n        setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref();\n    });\n  }\n\n  if (ACCESS_LOG_FILE) {\n    ++counter;\n    fs.stat(ACCESS_LOG_FILE, (err, stat) => {\n      if (err && !err.message.startsWith(\"ENOENT:\")) throw err;\n\n      if (\n        !(\n          stat &&\n          stat.dev === accessLogStat.dev &&\n          stat.ino === accessLogStat.ino\n        )\n      ) {\n        accessLogStream.end();\n        accessLogStream = fs.createWriteStream(null, {\n          fd: fs.openSync(ACCESS_LOG_FILE, \"a\"),\n        });\n        accessLogStat = fs.fstatSync(accessLogStream.fd);\n      }\n\n      if (--counter === 0)\n        setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref();\n    });\n  }\n\n  if (--counter === 0)\n    setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref();\n}\n\nexport function init(service: string, version: string): void {\n  defaultMeta.hostname = os.hostname();\n  defaultMeta.pid = process.pid;\n  defaultMeta.name = `genieacs-${service}`;\n  defaultMeta.version = version;\n\n  LOG_FILE = config.get(`${service.toUpperCase()}_LOG_FILE`);\n  ACCESS_LOG_FILE = config.get(`${service.toUpperCase()}_ACCESS_LOG_FILE`);\n\n  if (LOG_FILE) {\n    logStream = fs.createWriteStream(null, { fd: fs.openSync(LOG_FILE, \"a\") });\n    logStat = fs.fstatSync(logStream.fd);\n  }\n\n  if (ACCESS_LOG_FILE) {\n    accessLogStream = fs.createWriteStream(null, {\n      fd: fs.openSync(ACCESS_LOG_FILE, \"a\"),\n    });\n    accessLogStat = fs.fstatSync(accessLogStream.fd);\n  }\n\n  // Determine if logs are going to journald\n  const JOURNAL_STREAM = process.env[\"JOURNAL_STREAM\"];\n\n  if (JOURNAL_STREAM) {\n    const [dev, inode] = JOURNAL_STREAM.split(\":\").map(parseInt);\n\n    LOG_SYSTEMD = logStat.dev === dev && logStat.ino === inode;\n    ACCESS_LOG_SYSTEMD =\n      accessLogStat.dev === dev && accessLogStat.ino === inode;\n  }\n\n  if (LOG_FILE || ACCESS_LOG_FILE)\n    // Can't use setInterval as we need all workers to cehck at the same time\n    setTimeout(reopen, REOPEN_EVERY - (Date.now() % REOPEN_EVERY)).unref();\n}\n\nexport function close(): void {\n  accessLogStream.end();\n  logStream.end();\n}\n\nexport function flatten(\n  details: Record<string, unknown>,\n): Record<string, unknown> {\n  if (details.sessionContext) {\n    const sessionContext = details.sessionContext as SessionContext;\n    details.deviceId = sessionContext.deviceId;\n    details.remoteAddress = getRequestOrigin(\n      sessionContext.httpRequest,\n    ).remoteAddress;\n    delete details.sessionContext;\n  }\n\n  if (details.exception) {\n    const err = details.exception as Error;\n    details.exceptionName = err.name;\n    details.exceptionMessage = err.message;\n    details.exceptionStack = err.stack;\n    delete details.exception;\n  }\n\n  if (details.task) {\n    details.taskId = details.task[\"_id\"];\n    delete details.task;\n  }\n\n  if (details.rpc) {\n    const rpc = details.rpc as {\n      id: string;\n      acsRequest?: AcsRequest;\n      cpeRequest?: CpeRequest;\n      cpeFault?: CpeFault;\n    };\n    if (rpc.acsRequest) {\n      details.acsRequestId = rpc.id;\n      details.acsRequestName = rpc.acsRequest.name;\n      if (rpc.acsRequest[\"commandKey\"])\n        details.acsRequestCommandKey = rpc.acsRequest[\"commandKey\"];\n    } else if (rpc.cpeRequest) {\n      details.cpeRequestId = rpc.id;\n      if (rpc.cpeRequest.name === \"Inform\") {\n        details.informEvent = (rpc.cpeRequest as InformRequest).event.join(\",\");\n        details.informRetryCount = (rpc.cpeRequest as InformRequest).retryCount;\n      } else {\n        details.cpeRequestName = rpc.cpeRequest.name;\n        if (rpc.cpeRequest[\"commandKey\"])\n          details.cpeRequestCommandKey = rpc.cpeRequest[\"commandKey\"];\n      }\n    } else if (rpc.cpeFault) {\n      details.acsRequestId = rpc.id;\n      details.cpeFaultCode = rpc.cpeFault.detail.faultCode;\n      details.cpeFaultString = rpc.cpeFault.detail.faultString;\n    }\n    delete details.rpc;\n  }\n\n  if (details.fault) {\n    const fault = details.fault as Fault;\n    details.faultCode = fault.code;\n    details.faultMessage = fault.message;\n    delete details.fault;\n  }\n\n  // For genieacs-ui\n  if (details.context) {\n    details.remoteAddress = getRequestOrigin(\n      details.context[\"req\"],\n    ).remoteAddress;\n    if (details.context[\"state\"].user)\n      details.user = details.context[\"state\"].user.username;\n    delete details.context;\n  }\n\n  for (const [k, v] of Object.entries(details))\n    if (v == null) delete details[k];\n\n  return details;\n}\n\nfunction formatJson(\n  details: Record<string, unknown>,\n  systemd: boolean,\n): string {\n  if (systemd) {\n    let severity = \"\";\n    if (details.severity === \"info\") severity = \"<6>\";\n    else if (details.severity === \"warn\") severity = \"<4>\";\n    else if (details.severity === \"error\") severity = \"<3>\";\n\n    return `${severity}${JSON.stringify(flatten(details))}${os.EOL}`;\n  }\n\n  return `${JSON.stringify(flatten(details))}${os.EOL}`;\n}\n\nfunction formatSimple(\n  details: Record<string, unknown>,\n  systemd: boolean,\n): string {\n  const skip = {\n    user: true,\n    remoteAddress: true,\n    severity: true,\n    timestamp: true,\n    message: true,\n    deviceId: !!details.sessionContext,\n  };\n\n  flatten(details);\n\n  let remote = \"\";\n  if (details.remoteAddress) {\n    if (details.deviceId && skip[\"deviceId\"])\n      remote = `${details.remoteAddress} ${details.deviceId}: `;\n    else if (details.user)\n      remote = `${details.user}@${details.remoteAddress}: `;\n    else remote = `${details.remoteAddress}: `;\n  }\n\n  const keys = Object.keys(details);\n\n  let meta = \"\";\n\n  const kv = [];\n  for (const k of keys)\n    if (!skip[k]) kv.push(`${k}=${JSON.stringify(details[k])}`);\n\n  if (kv.length) meta = `; ${kv.join(\" \")}`;\n\n  if (systemd) {\n    let severity = \"\";\n    if (details.severity === \"info\") severity = \"<6>\";\n    else if (details.severity === \"warn\") severity = \"<4>\";\n    else if (details.severity === \"error\") severity = \"<3>\";\n\n    return `${severity}${remote}${details.message}${meta}${os.EOL}`;\n  }\n\n  return `${details.timestamp} [${(\n    details.severity as string\n  ).toUpperCase()}] ${remote}${details.message}${meta}${os.EOL}`;\n}\n\nfunction log(details: Record<string, unknown>): void {\n  details.timestamp = new Date().toISOString();\n  if (LOG_FORMAT === \"json\") {\n    details = Object.assign({}, defaultMeta, details);\n    logStream.write(formatJson(details, LOG_SYSTEMD));\n  } else {\n    logStream.write(formatSimple(details, LOG_SYSTEMD));\n  }\n}\n\nexport function info(details: Record<string, unknown>): void {\n  details.severity = \"info\";\n  log(details);\n}\n\nexport function warn(details: Record<string, unknown>): void {\n  details.severity = \"warn\";\n  log(details);\n}\n\nexport function error(details: Record<string, unknown>): void {\n  details.severity = \"error\";\n  log(details);\n}\n\nexport function accessLog(details: Record<string, unknown>): void {\n  details.timestamp = new Date().toISOString();\n  if (ACCESS_LOG_FORMAT === \"json\") {\n    Object.assign(details, defaultMeta);\n    accessLogStream.write(formatJson(details, ACCESS_LOG_SYSTEMD));\n  } else {\n    accessLogStream.write(formatSimple(details, ACCESS_LOG_SYSTEMD));\n  }\n}\n\nexport function accessInfo(details: Record<string, unknown>): void {\n  details.severity = \"info\";\n  accessLog(details);\n}\n\nexport function accessWarn(details: Record<string, unknown>): void {\n  details.severity = \"warn\";\n  accessLog(details);\n}\n\nexport function accessError(details: Record<string, unknown>): void {\n  details.severity = \"error\";\n  accessLog(details);\n}\n"
  },
  {
    "path": "lib/nbi.ts",
    "content": "import * as vm from \"node:vm\";\nimport { IncomingMessage, ServerResponse } from \"node:http\";\nimport { Collection, ObjectId } from \"mongodb\";\nimport { getRevision, getConfig } from \"./ui/local-cache.ts\";\nimport { filesBucket, collections } from \"./db/db.ts\";\nimport { optimizeProjection } from \"./db/util.ts\";\nimport * as query from \"./query.ts\";\nimport * as apiFunctions from \"./api-functions.ts\";\nimport * as cache from \"./cache.ts\";\nimport { version as VERSION } from \"../package.json\";\nimport { ping } from \"./ping.ts\";\nimport * as logger from \"./logger.ts\";\nimport { flattenDevice } from \"./ui/db.ts\";\nimport { getRequestOrigin } from \"./forwarded.ts\";\nimport { acquireLock, releaseLock } from \"./lock.ts\";\nimport { ResourceLockedError } from \"./common/errors.ts\";\nimport Expression from \"./common/expression.ts\";\n\nconst DEVICE_TASKS_REGEX = /^\\/devices\\/([a-zA-Z0-9\\-_%]+)\\/tasks\\/?$/;\nconst TASKS_REGEX = /^\\/tasks\\/([a-zA-Z0-9\\-_%]+)(\\/[a-zA-Z_]*)?$/;\nconst TAGS_REGEX =\n  /^\\/devices\\/([a-zA-Z0-9\\-_%]+)\\/tags\\/([a-zA-Z0-9\\-_%]+)\\/?$/;\nconst PRESETS_REGEX = /^\\/presets\\/([a-zA-Z0-9\\-_%]+)\\/?$/;\nconst OBJECTS_REGEX = /^\\/objects\\/([a-zA-Z0-9\\-_%]+)\\/?$/;\nconst FILES_REGEX = /^\\/files\\/([a-zA-Z0-9%!*'();:@&=+$,?#[\\]\\-_.~]+)\\/?$/;\nconst PING_REGEX = /^\\/ping\\/([a-zA-Z0-9\\-_.:]+)\\/?$/;\nconst QUERY_REGEX = /^\\/([a-zA-Z0-9_]+)\\/?$/;\nconst DELETE_DEVICE_REGEX = /^\\/devices\\/([a-zA-Z0-9\\-_%]+)\\/?$/;\nconst PROVISIONS_REGEX = /^\\/provisions\\/([a-zA-Z0-9\\-_%]+)\\/?$/;\nconst VIRTUAL_PARAMETERS_REGEX =\n  /^\\/virtual_parameters\\/([a-zA-Z0-9\\-_%]+)\\/?$/;\nconst FAULTS_REGEX = /^\\/faults\\/([a-zA-Z0-9\\-_%:]+)\\/?$/;\n\nasync function getBody(request: IncomingMessage): Promise<Buffer> {\n  const chunks: Buffer[] = [];\n  let readableEnded = false;\n  request.on(\"end\", () => {\n    readableEnded = true;\n  });\n  for await (const chunk of request) chunks.push(chunk);\n  // In Node versions prior to 15, the stream will not emit an error if the\n  // connection is closed before the stream is finished.\n  // For Node 12.9+ we can just use stream.readableEnded\n  if (!readableEnded) throw new Error(\"Connection closed\");\n  return Buffer.concat(chunks);\n}\n\nexport async function listener(\n  request: IncomingMessage,\n  response: ServerResponse,\n): Promise<void> {\n  response.setHeader(\"GenieACS-Version\", VERSION);\n\n  const origin = getRequestOrigin(request);\n  const url = new URL(\n    request.url,\n    (origin.encrypted ? \"https://\" : \"http://\") + origin.host,\n  );\n\n  const body = await getBody(request).catch(() => null);\n  // Ignore incomplete requests\n  if (body == null) return;\n\n  logger.accessInfo(\n    Object.assign({}, Object.fromEntries(url.searchParams), {\n      remoteAddress: origin.remoteAddress,\n      message: `${request.method} ${url.pathname}`,\n    }),\n  );\n  return handler(request, response, url, body);\n}\n\nasync function handler(\n  request: IncomingMessage,\n  response: ServerResponse,\n  url: URL,\n  body: Buffer,\n): Promise<void> {\n  if (PRESETS_REGEX.test(url.pathname)) {\n    const presetName = decodeURIComponent(PRESETS_REGEX.exec(url.pathname)[1]);\n    if (request.method === \"PUT\") {\n      let preset;\n      try {\n        preset = JSON.parse(body.toString());\n      } catch (err) {\n        response.writeHead(400);\n        response.end(`${err.name}: ${err.message}`);\n        return;\n      }\n      preset._id = presetName;\n      await collections.presets.replaceOne({ _id: presetName }, preset, {\n        upsert: true,\n      });\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else if (request.method === \"DELETE\") {\n      await collections.presets.deleteOne({ _id: presetName });\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else {\n      response.writeHead(405, { Allow: \"PUT, DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (OBJECTS_REGEX.test(url.pathname)) {\n    const objectName = decodeURIComponent(OBJECTS_REGEX.exec(url.pathname)[1]);\n    if (request.method === \"PUT\") {\n      let object;\n      try {\n        object = JSON.parse(body.toString());\n      } catch (err) {\n        response.writeHead(400);\n        response.end(`${err.name}: ${err.message}`);\n        return;\n      }\n      object._id = objectName;\n      await collections.objects.replaceOne({ _id: objectName }, object, {\n        upsert: true,\n      });\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else if (request.method === \"DELETE\") {\n      await collections.objects.deleteOne({ _id: objectName });\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else {\n      response.writeHead(405, { Allow: \"PUT, DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (PROVISIONS_REGEX.test(url.pathname)) {\n    const provisionName = decodeURIComponent(\n      PROVISIONS_REGEX.exec(url.pathname)[1],\n    );\n    if (request.method === \"PUT\") {\n      const object = {\n        _id: provisionName,\n        script: body.toString(),\n      };\n\n      try {\n        new vm.Script(`\"use strict\";(function(){\\n${object.script}\\n})();`);\n      } catch (err) {\n        response.writeHead(400);\n        response.end(`${err.name}: ${err.message}`);\n        return;\n      }\n\n      await collections.provisions.replaceOne({ _id: provisionName }, object, {\n        upsert: true,\n      });\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else if (request.method === \"DELETE\") {\n      await collections.provisions.deleteOne({ _id: provisionName });\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else {\n      response.writeHead(405, { Allow: \"PUT, DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (VIRTUAL_PARAMETERS_REGEX.test(url.pathname)) {\n    const virtualParameterName = decodeURIComponent(\n      VIRTUAL_PARAMETERS_REGEX.exec(url.pathname)[1],\n    );\n    if (request.method === \"PUT\") {\n      const object = {\n        _id: virtualParameterName,\n        script: body.toString(),\n      };\n\n      try {\n        new vm.Script(`\"use strict\";(function(){\\n${object.script}\\n})();`);\n      } catch (err) {\n        response.writeHead(400);\n        response.end(`${err.name}: ${err.message}`);\n        return;\n      }\n\n      await collections.virtualParameters.replaceOne(\n        { _id: virtualParameterName },\n        object,\n        { upsert: true },\n      );\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else if (request.method === \"DELETE\") {\n      await collections.virtualParameters.deleteOne({\n        _id: virtualParameterName,\n      });\n      await cache.del(\"cwmp-local-cache-hash\");\n      response.writeHead(200);\n      response.end();\n    } else {\n      response.writeHead(405, { Allow: \"PUT, DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (TAGS_REGEX.test(url.pathname)) {\n    const r = TAGS_REGEX.exec(url.pathname);\n    const deviceId = decodeURIComponent(r[1]);\n    const tag = decodeURIComponent(r[2]);\n    if (request.method === \"POST\") {\n      const updateRes = await collections.devices.updateOne(\n        { _id: deviceId },\n        { $addToSet: { _tags: tag } },\n      );\n\n      if (!updateRes.matchedCount) {\n        response.writeHead(404);\n        response.end(\"No such device\");\n        return;\n      }\n\n      response.writeHead(200);\n      response.end();\n    } else if (request.method === \"DELETE\") {\n      const updateRes = await collections.devices.updateOne(\n        { _id: deviceId },\n        { $pull: { _tags: tag } },\n      );\n\n      if (!updateRes.matchedCount) {\n        response.writeHead(404);\n        response.end(\"No such device\");\n        return;\n      }\n\n      response.writeHead(200);\n      response.end();\n    } else {\n      response.writeHead(405, { Allow: \"POST, DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (FAULTS_REGEX.test(url.pathname)) {\n    if (request.method === \"DELETE\") {\n      const faultId = decodeURIComponent(FAULTS_REGEX.exec(url.pathname)[1]);\n      try {\n        await apiFunctions.deleteFault(faultId);\n      } catch (err) {\n        if (err instanceof ResourceLockedError) {\n          response.writeHead(503);\n          response.end(\"Device is in session\");\n          return;\n        }\n        throw err;\n      }\n\n      response.writeHead(200);\n      response.end();\n    } else {\n      response.writeHead(405, { Allow: \"DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (DEVICE_TASKS_REGEX.test(url.pathname)) {\n    if (request.method === \"POST\") {\n      const deviceId = decodeURIComponent(\n        DEVICE_TASKS_REGEX.exec(url.pathname)[1],\n      );\n\n      const conReq = url.searchParams.has(\"connection_request\");\n      let task;\n      if (body.length) {\n        try {\n          task = JSON.parse(body.toString());\n          task.device = deviceId;\n        } catch (err) {\n          response.writeHead(400);\n          response.end(`${err.name}: ${err.message}`);\n          return;\n        }\n      }\n\n      if (!task && !conReq) {\n        response.writeHead(400);\n        response.end();\n        return;\n      }\n\n      if (!task || !conReq) {\n        const dev = await collections.devices.findOne({ _id: deviceId });\n        if (!dev) {\n          response.writeHead(404);\n          response.end(\"No such device\");\n          return;\n        }\n\n        if (task) {\n          await apiFunctions.insertTasks(task);\n          response.writeHead(202, { \"Content-Type\": \"application/json\" });\n          response.end(JSON.stringify(task));\n        } else {\n          const status = await apiFunctions.connectionRequest(\n            deviceId,\n            flattenDevice(dev),\n          );\n          if (status) {\n            response.writeHead(504, status);\n            response.end(status);\n          } else {\n            response.writeHead(200);\n            response.end();\n          }\n        }\n        return;\n      }\n\n      const socketTimeout: number = request.socket.timeout;\n\n      // Extend socket timeout while waiting for session\n      if (socketTimeout) request.socket.setTimeout(300000);\n\n      const token = await acquireLock(`cwmp_session_${deviceId}`, 5000, 30000);\n      if (!token) {\n        // Restore socket timeout\n        if (socketTimeout) request.socket.setTimeout(socketTimeout);\n        const dev = await collections.devices.findOne({ _id: deviceId });\n        if (!dev) {\n          response.writeHead(404);\n          response.end(\"No such device\");\n          return;\n        }\n\n        await apiFunctions.insertTasks(task);\n        response.writeHead(202, \"Task queued but not processed\", {\n          \"Content-Type\": \"application/json\",\n        });\n        response.end(JSON.stringify(task));\n        return;\n      }\n\n      let dev;\n\n      try {\n        dev = await collections.devices.findOne({ _id: deviceId });\n        if (!dev) {\n          response.writeHead(404);\n          response.end(\"No such device\");\n          return;\n        }\n        await apiFunctions.insertTasks(task);\n      } finally {\n        await releaseLock(`cwmp_session_${deviceId}`, token);\n      }\n\n      const lastInform = (dev[\"_lastInform\"] as Date).getTime();\n      const device = flattenDevice(dev);\n\n      const configCallback = (e: Expression): Expression.Literal => {\n        if (e instanceof Expression.Literal) return e;\n        else if (e instanceof Expression.Parameter) {\n          const p = device[e.path.toString()];\n          if (p != null) return new Expression.Literal(p);\n        } else if (e instanceof Expression.FunctionCall) {\n          if (e.name === \"NOW\") return new Expression.Literal(Date.now());\n          if (e.name === \"REMOTE_ADDRESS\") {\n            for (const root of [\"InternetGatewayDevice\", \"Device\"]) {\n              const p = device[`${root}.ManagementServer.ConnectionRequestURL`];\n              if (p != null)\n                return new Expression.Literal(new URL(p as string).hostname);\n            }\n          }\n        }\n        return new Expression.Literal(null);\n      };\n\n      let onlineThreshold: number;\n      if (url.searchParams.has(\"timeout\")) {\n        onlineThreshold = parseInt(url.searchParams.get(\"timeout\"));\n      } else {\n        const revision = await getRevision();\n        onlineThreshold = getConfig(\n          revision,\n          \"cwmp.deviceOnlineThreshold\",\n          4000,\n          configCallback,\n        );\n      }\n\n      let status = await apiFunctions.connectionRequest(deviceId, device);\n      if (!status) {\n        const sessionStarted = await apiFunctions.awaitSessionStart(\n          deviceId,\n          lastInform,\n          onlineThreshold,\n        );\n        if (!sessionStarted) {\n          status = \"Task queued but not processed\";\n        } else {\n          const sessionEnded = await apiFunctions.awaitSessionEnd(\n            deviceId,\n            120000,\n          );\n          if (!sessionEnded) {\n            status = \"Task queued but not processed\";\n          } else {\n            const f = await collections.faults.count({\n              _id: `${deviceId}:task_${task._id}`,\n            });\n            if (f) status = \"Task faulted\";\n          }\n        }\n      }\n\n      // Restore socket timeout\n      if (socketTimeout) request.socket.setTimeout(socketTimeout);\n\n      if (status) {\n        response.writeHead(202, status, { \"Content-Type\": \"application/json\" });\n        response.end(JSON.stringify(task));\n      } else {\n        response.writeHead(200, { \"Content-Type\": \"application/json\" });\n        response.end(JSON.stringify(task));\n      }\n    } else {\n      response.writeHead(405, { Allow: \"POST\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (TASKS_REGEX.test(url.pathname)) {\n    const r = TASKS_REGEX.exec(url.pathname);\n    const taskId = decodeURIComponent(r[1]);\n    const action = r[2];\n    if (!action || action === \"/\") {\n      if (request.method === \"DELETE\") {\n        const task = await collections.tasks.findOne(\n          { _id: new ObjectId(taskId) },\n          { projection: { device: 1 } },\n        );\n\n        if (!task) {\n          response.writeHead(404);\n          response.end(\"Task not found\");\n          return;\n        }\n\n        const deviceId = task.device;\n        const token = await acquireLock(`cwmp_session_${deviceId}`, 5000);\n        if (!token) {\n          response.writeHead(503);\n          response.end(\"Device is in session\");\n          return;\n        }\n\n        try {\n          await Promise.all([\n            collections.tasks.deleteOne({ _id: new ObjectId(taskId) }),\n            collections.faults.deleteOne({ _id: `${deviceId}:task_${taskId}` }),\n          ]);\n        } finally {\n          await releaseLock(`cwmp_session_${deviceId}`, token);\n        }\n\n        response.writeHead(200);\n        response.end();\n      } else {\n        response.writeHead(405, { Allow: \"PUT DELETE\" });\n        response.end(\"405 Method Not Allowed\");\n      }\n    } else if (action === \"/retry\") {\n      if (request.method === \"POST\") {\n        const task = await collections.tasks.findOne(\n          { _id: new ObjectId(taskId) },\n          { projection: { device: 1 } },\n        );\n\n        const deviceId = task.device;\n        const token = await acquireLock(`cwmp_session_${deviceId}`, 5000);\n        if (!token) {\n          response.writeHead(503);\n          response.end(\"Device is in session\");\n          return;\n        }\n        try {\n          await collections.faults.deleteOne({\n            _id: `${deviceId}:task_${taskId}`,\n          });\n        } finally {\n          await releaseLock(`cwmp_session_${deviceId}`, token);\n        }\n\n        response.writeHead(200);\n        response.end();\n      } else {\n        response.writeHead(405, { Allow: \"POST\" });\n        response.end(\"405 Method Not Allowed\");\n      }\n    } else {\n      response.writeHead(404);\n      response.end();\n    }\n  } else if (FILES_REGEX.test(url.pathname)) {\n    const filename = decodeURIComponent(FILES_REGEX.exec(url.pathname)[1]);\n    if (request.method === \"PUT\") {\n      const metadata = {\n        fileType: request.headers.filetype,\n        oui: request.headers.oui,\n        productClass: request.headers.productclass,\n        version: request.headers.version,\n      };\n      try {\n        await filesBucket.delete(filename as unknown as ObjectId);\n      } catch {\n        // Ignore error if file doesn't exist\n      }\n\n      return new Promise((resolve, reject) => {\n        const uploadStream = filesBucket.openUploadStreamWithId(\n          filename as unknown as ObjectId,\n          filename,\n          {\n            metadata: metadata,\n          },\n        );\n\n        uploadStream.on(\"error\", reject);\n\n        uploadStream.end(body, () => {\n          response.writeHead(201);\n          response.end();\n          resolve();\n        });\n      });\n    } else if (request.method === \"DELETE\") {\n      try {\n        await filesBucket.delete(filename as unknown as ObjectId);\n      } catch (err) {\n        if (err.message.startsWith(\"FileNotFound\")) {\n          response.writeHead(404);\n          response.end(\"404 Not Found\");\n          return;\n        }\n        throw err;\n      }\n      response.writeHead(200);\n      response.end();\n    } else {\n      response.writeHead(405, { Allow: \"PUT, DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n    }\n  } else if (PING_REGEX.test(url.pathname)) {\n    const host = decodeURIComponent(PING_REGEX.exec(url.pathname)[1]);\n    return new Promise((resolve) => {\n      ping(host, (err, res, stdout) => {\n        if (err) {\n          if (!res) {\n            response.writeHead(500, { Connection: \"close\" });\n            response.end(`${err.name}: ${err.message}`);\n            return;\n          }\n          response.writeHead(404, { \"Cache-Control\": \"no-cache\" });\n          response.end(`${err.name}: ${err.message}`);\n          return;\n        }\n\n        response.writeHead(200, {\n          \"Content-Type\": \"text/plain\",\n          \"Cache-Control\": \"no-cache\",\n        });\n        response.end(stdout);\n        resolve();\n      });\n    });\n  } else if (DELETE_DEVICE_REGEX.test(url.pathname)) {\n    if (request.method !== \"DELETE\") {\n      response.writeHead(405, { Allow: \"DELETE\" });\n      response.end(\"405 Method Not Allowed\");\n      return;\n    }\n\n    const deviceId = decodeURIComponent(\n      DELETE_DEVICE_REGEX.exec(url.pathname)[1],\n    );\n\n    try {\n      await apiFunctions.deleteDevice(deviceId);\n    } catch (err) {\n      if (err instanceof ResourceLockedError) {\n        response.writeHead(503);\n        response.end(\"Device is in session\");\n        return;\n      }\n      throw err;\n    }\n\n    response.writeHead(200);\n    response.end();\n  } else if (QUERY_REGEX.test(url.pathname)) {\n    let collectionName = QUERY_REGEX.exec(url.pathname)[1];\n\n    // Convert to camel case\n    let i = collectionName.indexOf(\"_\");\n    while (i++ >= 0) {\n      const up =\n        i < collectionName.length ? collectionName[i].toUpperCase() : \"\";\n      collectionName =\n        collectionName.slice(0, i - 1) + up + collectionName.slice(i + 1);\n      i = collectionName.indexOf(\"_\", i);\n    }\n\n    if (request.method !== \"GET\" && request.method !== \"HEAD\") {\n      response.writeHead(405, { Allow: \"GET, HEAD\" });\n      response.end(\"405 Method Not Allowed\");\n      return;\n    }\n\n    const collection = collections[collectionName] as Collection<unknown>;\n    if (!collection) {\n      response.writeHead(404);\n      response.end(\"404 Not Found\");\n      return;\n    }\n\n    let q = {};\n    if (url.searchParams.has(\"query\")) {\n      try {\n        q = JSON.parse(url.searchParams.get(\"query\") as string);\n      } catch (err) {\n        response.writeHead(400);\n        response.end(`${err.name}: ${err.message}`);\n        return;\n      }\n    }\n\n    switch (collectionName) {\n      case \"devices\":\n        q = query.expand(q);\n        break;\n      case \"tasks\":\n        q = query.sanitizeQueryTypes(q, {\n          _id: (v) => new ObjectId(v as string),\n          timestamp: (v) => new Date(v as number),\n          retries: Number,\n        });\n        break;\n      case \"faults\":\n        q = query.sanitizeQueryTypes(q, {\n          timestamp: (v) => new Date(v as number),\n          retries: Number,\n        });\n    }\n\n    let projection = null;\n    if (url.searchParams.has(\"projection\")) {\n      projection = {};\n      for (const p of (url.searchParams.get(\"projection\") as string).split(\",\"))\n        projection[p.trim()] = 1;\n      projection = optimizeProjection(projection);\n    }\n\n    const cur = collection.find(q, { projection: projection });\n\n    if (url.searchParams.has(\"sort\")) {\n      let s;\n      try {\n        s = JSON.parse(url.searchParams.get(\"sort\") as string);\n      } catch (err) {\n        response.writeHead(400);\n        response.end(`${err.name}: ${err.message}`);\n        return;\n      }\n      const sort = {};\n      for (const [k, v] of Object.entries(s)) {\n        if (k[k.lastIndexOf(\".\") + 1] !== \"_\" && collectionName === \"devices\")\n          sort[`${k}._value`] = v;\n        else sort[k] = v;\n      }\n\n      cur.sort(sort);\n    }\n\n    const total = await collection.countDocuments(q);\n\n    response.writeHead(200, {\n      \"Content-Type\": \"application/json\",\n      total: total,\n    });\n\n    if (request.method === \"HEAD\") {\n      response.end();\n      return;\n    }\n\n    if (url.searchParams.has(\"skip\"))\n      cur.skip(parseInt(url.searchParams.get(\"skip\") as string));\n\n    if (url.searchParams.has(\"limit\"))\n      cur.limit(parseInt(url.searchParams.get(\"limit\") as string));\n\n    response.write(\"[\\n\");\n    i = 0;\n    for await (const item of cur) {\n      if (i++) response.write(\",\\n\");\n      response.write(JSON.stringify(item));\n    }\n    response.end(\"\\n]\");\n  } else {\n    response.writeHead(404);\n    response.end(\"404 Not Found\");\n  }\n}\n"
  },
  {
    "path": "lib/ping.ts",
    "content": "import { platform } from \"node:os\";\nimport { exec } from \"node:child_process\";\nimport { domainToASCII } from \"node:url\";\n\nexport interface PingResult {\n  packetsTransmitted: number;\n  packetsReceived: number;\n  packetLoss: number;\n  min: number;\n  avg: number;\n  max: number;\n  mdev: number;\n}\n\nfunction isValidHost(host: string): boolean {\n  // Valid chars in IPv4, IPv6, domain names\n  if (/^[a-zA-Z0-9\\-.:[\\]-]+$/.test(host)) return true;\n\n  // Check if input is an IDN convert to Punycode\n  // Can't merge with above because domainToASCII doesn't accept IP addresses\n  return /^[a-zA-Z0-9\\-.:[\\]-]+$/.test(domainToASCII(host));\n}\n\nexport function parsePing(osPlatform: string, stdout: string): PingResult {\n  let parseRegExp1: RegExp, parseRegExp2: RegExp, parsed: PingResult;\n  switch (osPlatform) {\n    case \"linux\":\n      parseRegExp1 =\n        /(\\d+) packets transmitted, (\\d+) .*received, ([\\d.]+)% .*loss[^]*= ([\\d.]+)\\/([\\d.]+)\\/([\\d.]+)\\/([\\d.]+)/;\n      parseRegExp2 =\n        /(\\d+) packets transmitted, (\\d+) .*received, ([\\d.]+)% .*loss/;\n      break;\n\n    case \"freebsd\":\n      parseRegExp1 =\n        /(\\d+) packets transmitted, (\\d+) packets received, ([\\d.]+)% packet loss\\nround-trip min\\/avg\\/max\\/stddev = ([\\d.]+)\\/([\\d.]+)\\/([\\d.]+)\\/([\\d.]+) ms/;\n      parseRegExp2 =\n        /(\\d+) packets transmitted, (\\d+) packets received, ([\\d.]+)% packet loss/;\n      break;\n  }\n\n  const m1 = stdout.match(parseRegExp1);\n  if (m1) {\n    parsed = {\n      packetsTransmitted: +m1[1],\n      packetsReceived: +m1[2],\n      packetLoss: +m1[3],\n      min: +m1[4],\n      avg: +m1[5],\n      max: +m1[6],\n      mdev: +m1[7],\n    };\n  } else {\n    const m2 = stdout.match(parseRegExp2);\n    if (m2) {\n      parsed = {\n        packetsTransmitted: +m2[1],\n        packetsReceived: +m2[2],\n        packetLoss: +m2[3],\n        min: null,\n        avg: null,\n        max: null,\n        mdev: null,\n      };\n    }\n  }\n  return parsed;\n}\n\nexport function ping(\n  host: string,\n  callback: (err: Error, res?: PingResult, stdout?: string) => void,\n): void {\n  // Validate input to prevent possible remote code execution\n  // Credit to Alex Hordijk for reporting this vulnerability\n  if (!isValidHost(host)) return callback(new Error(\"Invalid host\"));\n  host = host.replace(\"[\", \"\").replace(\"]\", \"\");\n  let cmd: string;\n\n  switch (platform()) {\n    case \"linux\":\n      cmd = `ping -w 1 -i 0.2 -c 3 ${host}`;\n      break;\n    case \"freebsd\":\n      // Send a single packet because on FreeBSD only superuser can send\n      // packets that are only 200 ms apart.\n      cmd = `ping -t 1 -c 3 ${host}`;\n      break;\n    default:\n      return callback(new Error(\"Platform not supported\"));\n  }\n\n  exec(cmd, (err, stdout) => {\n    if (err) return callback(err);\n    const parsed: PingResult = parsePing(platform(), stdout);\n    return callback(err, parsed, stdout);\n  });\n}\n"
  },
  {
    "path": "lib/query.ts",
    "content": "function isObject(obj: any): boolean {\n  return Object.prototype.toString.call(obj) === \"[object Object]\";\n}\n\nfunction stringToRegexp(input, flags?): RegExp | false {\n  if (input.indexOf(\"*\") === -1) return false;\n\n  let output = input.replace(/[[\\]\\\\^$.|?+()]/, \"\\\\$&\");\n  if (output[0] === \"*\") output = output.replace(/^\\*+/g, \"\");\n  else output = \"^\" + output;\n\n  if (output[output.length - 1] === \"*\") output = output.replace(/\\*+$/g, \"\");\n  else output = output + \"$\";\n\n  output = output.replace(/[*]/, \".*\");\n  return new RegExp(output, flags);\n}\n\nfunction normalize(input): any {\n  if (typeof input === \"string\") {\n    const vals: any = [input];\n    const m = /^\\/(.*?)\\/(g?i?m?y?)$/.exec(input);\n    if (m) vals.push({ $regex: new RegExp(m[1], m[2]) });\n\n    if (+input === parseFloat(input)) vals.push(+input);\n\n    const d = new Date(input);\n    if (input.length >= 8 && d.getFullYear() > 1983) vals.push(d);\n\n    const r = stringToRegexp(input);\n    if (r !== false) vals.push({ $regex: r });\n\n    return vals;\n  }\n  return input;\n}\n\nconst EXPAND_OPS = new Set([\n  \"$eq\",\n  \"$gt\",\n  \"$gte\",\n  \"$in\",\n  \"$lt\",\n  \"$lte\",\n  \"$ne\",\n  \"$nin\",\n]);\n\nfunction expandValue(value: unknown): unknown[] {\n  if (Array.isArray(value)) {\n    let a = [];\n    for (const j of value) a = a.concat(expandValue(j));\n    return [a];\n  } else if (!isObject(value)) {\n    const n = normalize(value);\n    if (!Array.isArray(n)) return [n];\n    else return n;\n  }\n\n  const objs = [];\n  const indices = [];\n  const keys = [];\n  const values = [];\n  for (const [k, v] of Object.entries(value)) {\n    keys.push(k);\n    if (EXPAND_OPS.has(k)) values.push(expandValue(v));\n    else values.push([v]);\n    indices.push(0);\n  }\n\n  let i = 0;\n  while (i < indices.length) {\n    const obj = {};\n    for (let j = 0; j < keys.length; ++j) obj[keys[j]] = values[j][indices[j]];\n    objs.push(obj);\n\n    for (i = 0; i < indices.length; ++i) {\n      indices[i] += 1;\n      if (indices[i] < values[i].length) break;\n      indices[i] = 0;\n    }\n  }\n  return objs;\n}\n\nfunction permute(param, val): any[] {\n  const conditions = [];\n  const values = expandValue(val);\n\n  if (param[param.lastIndexOf(\".\") + 1] !== \"_\") param += \"._value\";\n\n  for (const v of values) {\n    const obj = {};\n    obj[param] = v;\n    conditions.push(obj);\n  }\n\n  return conditions;\n}\n\nexport function expand(\n  query: Record<string, unknown>,\n): Record<string, unknown> {\n  const newQuery = {};\n  for (const [k, v] of Object.entries(query)) {\n    if (k[0] === \"$\") {\n      // Operator\n      newQuery[k] = (v as any[]).map((e) => expand(e));\n    } else {\n      const conditions = permute(k, v);\n      if (conditions.length > 1) {\n        newQuery[\"$and\"] = newQuery[\"$and\"] || [];\n        if (v && (v[\"$ne\"] != null || v[\"$not\"] != null)) {\n          if (Object.keys(v).length > 1)\n            throw new Error(\"Cannot mix $ne or $not with other operators\");\n          for (const c of conditions) newQuery[\"$and\"].push(c);\n        } else {\n          newQuery[\"$and\"].push({ $or: conditions });\n        }\n      } else {\n        Object.assign(newQuery, conditions[0]);\n      }\n    }\n  }\n\n  return newQuery;\n}\n\nexport function sanitizeQueryTypes(\n  query: Record<string, unknown>,\n  types: Record<string, (v: unknown) => unknown>,\n): Record<string, unknown> {\n  for (const [k, v] of Object.entries(query)) {\n    if (k[0] === \"$\") {\n      // Logical operator\n      for (const vv of v as any[]) sanitizeQueryTypes(vv, types);\n    } else if (k in types) {\n      if (isObject(v)) {\n        for (const [kk, vv] of Object.entries(v)) {\n          switch (kk) {\n            case \"$in\":\n            case \"$nin\":\n              for (let i = 0; i < vv.length; ++i) vv[i] = types[k](vv[i]);\n              break;\n            case \"$eq\":\n            case \"$gt\":\n            case \"$gte\":\n            case \"$lt\":\n            case \"$lte\":\n            case \"$ne\":\n              v[kk] = types[k](vv);\n              break;\n            case \"$exists\":\n            case \"$type\":\n              // Ignore\n              break;\n            default:\n              throw new Error(\"Operator not supported\");\n          }\n        }\n      } else {\n        query[k] = types[k](query[k]);\n      }\n    }\n  }\n\n  return query;\n}\n"
  },
  {
    "path": "lib/sandbox.ts",
    "content": "import * as vm from \"node:vm\";\nimport seedrandom from \"seedrandom\";\nimport * as device from \"./device.ts\";\nimport * as extensions from \"./extensions.ts\";\nimport * as logger from \"./logger.ts\";\nimport * as scheduling from \"./scheduling.ts\";\nimport Path from \"./common/path.ts\";\nimport { Fault, SessionContext, ScriptResult } from \"./types.ts\";\n\n// Used for throwing to exit user script and commit\nconst COMMIT = Symbol();\n\n// Used to execute extensions and restart\nconst EXT = Symbol();\n\nconst UNDEFINED = undefined;\n\nconst context = vm.createContext(undefined, { microtaskMode: \"afterEvaluate\" });\n\nlet state;\n\nconst runningExtensions = new WeakMap<\n  SessionContext,\n  Map<string, Promise<Fault>>\n>();\nfunction runExtension(sessionContext, key, extCall): Promise<Fault> {\n  let re = runningExtensions.get(sessionContext);\n  if (!re) {\n    re = new Map<string, Promise<Fault>>();\n    runningExtensions.set(sessionContext, re);\n  }\n\n  let prom = re.get(key);\n  if (prom == null) {\n    re.set(\n      key,\n      (prom = new Promise((resolve, reject) => {\n        extensions\n          .run(extCall)\n          .then(({ fault, value }) => {\n            re.delete(key);\n            if (!fault) sessionContext.extensionsCache[key] = value;\n            resolve(fault);\n          })\n          .catch(reject);\n      })),\n    );\n  }\n\n  return prom;\n}\n\nclass SandboxDate {\n  public constructor(\n    ...argumentList: [\n      number?,\n      number?,\n      number?,\n      number?,\n      number?,\n      number?,\n      number?,\n    ]\n  ) {\n    if (argumentList.length) return new Date(...argumentList);\n\n    return new Date(state.sessionContext.timestamp);\n  }\n\n  public static now(intervalOrCron, variance): number {\n    let t = state.sessionContext.timestamp;\n\n    if (typeof intervalOrCron === \"number\") {\n      if (variance == null) variance = intervalOrCron;\n\n      let offset = 0;\n      if (variance)\n        offset = scheduling.variance(state.sessionContext.deviceId, variance);\n\n      t = scheduling.interval(t, intervalOrCron, offset);\n    } else if (typeof intervalOrCron === \"string\") {\n      let offset = 0;\n      if (variance)\n        offset = scheduling.variance(state.sessionContext.deviceId, variance);\n      const cron = scheduling.parseCron(intervalOrCron);\n      t = scheduling.cron(t, cron, offset)[0];\n    } else if (intervalOrCron) {\n      throw new Error(\"Invalid Date.now() argument\");\n    }\n\n    return t;\n  }\n\n  public static parse(dateString: string): number {\n    return Date.parse(dateString);\n  }\n\n  public static UTC(\n    ...args: [number, number?, number?, number?, number?, number?, number?]\n  ): number {\n    return Date.UTC(...args);\n  }\n}\n\nfunction random(): number {\n  if (!state.rng) state.rng = seedrandom(state.sessionContext.deviceId);\n\n  return state.rng();\n}\n\nrandom.seed = function (s) {\n  state.rng = seedrandom(s);\n};\n\nclass ParameterWrapper {\n  public constructor(path: Path, attributes, unpacked?, unpackedRevision?) {\n    for (const attrName of attributes) {\n      Object.defineProperty(this, attrName, {\n        get: function () {\n          if (state.uncommitted) commit();\n\n          if (state.revision !== unpackedRevision) {\n            unpackedRevision = state.revision;\n            unpacked = device.unpack(\n              state.sessionContext.deviceData,\n              path,\n              state.revision,\n            );\n          }\n\n          if (!unpacked.length) return UNDEFINED;\n\n          const attr = state.sessionContext.deviceData.attributes.get(\n            unpacked[0],\n            state.revision,\n          )[attrName];\n\n          if (!attr) return UNDEFINED;\n\n          return attr[1];\n        },\n      });\n    }\n\n    Object.defineProperty(this, \"path\", {\n      get: function () {\n        if (state.uncommitted) commit();\n\n        if (state.revision !== unpackedRevision) {\n          unpackedRevision = state.revision;\n          unpacked = device.unpack(\n            state.sessionContext.deviceData,\n            path,\n            state.revision,\n          );\n        }\n\n        if (!unpacked.length) return UNDEFINED;\n\n        return unpacked[0].toString();\n      },\n    });\n\n    Object.defineProperty(this, \"size\", {\n      get: function () {\n        if (state.uncommitted) commit();\n\n        if (state.revision !== unpackedRevision) {\n          unpackedRevision = state.revision;\n          unpacked = device.unpack(\n            state.sessionContext.deviceData,\n            path,\n            state.revision,\n          );\n        }\n\n        if (!unpacked.length) return UNDEFINED;\n\n        return unpacked.length;\n      },\n    });\n\n    this[Symbol.iterator] = function* () {\n      if (state.uncommitted) commit();\n\n      if (state.revision !== unpackedRevision) {\n        unpackedRevision = state.revision;\n        unpacked = device.unpack(\n          state.sessionContext.deviceData,\n          path,\n          state.revision,\n        );\n      }\n\n      for (const p of unpacked)\n        yield new ParameterWrapper(p, attributes, [p], state.revision);\n    };\n  }\n}\n\nfunction declare(\n  path: string,\n  timestamps: { [attr: string]: number },\n  values: { [attr: string]: any },\n): ParameterWrapper {\n  state.uncommitted = true;\n  if (!timestamps) timestamps = {};\n\n  if (!values) values = {};\n\n  const parsedPath = Path.parse(path);\n\n  const declaration = {\n    path: parsedPath,\n    pathGet: 1,\n    pathSet: null,\n    attrGet: null,\n    attrSet: null,\n    defer: true,\n  };\n\n  const attrs = new Set();\n\n  for (const [attrName, attrValue] of Object.entries(values)) {\n    if (attrName === \"path\") {\n      declaration.pathSet = attrValue;\n    } else {\n      attrs.add(attrName);\n      if (!declaration.attrGet) declaration.attrGet = {};\n      if (!declaration.attrSet) declaration.attrSet = {};\n      declaration.attrGet[attrName] = 1;\n      if (attrName === \"value\" && !Array.isArray(values.value))\n        declaration.attrSet.value = [values.value];\n      else declaration.attrSet[attrName] = values[attrName];\n    }\n  }\n\n  for (const [attrName, attrTimestamp] of Object.entries(timestamps)) {\n    if (!(attrTimestamp >= 1)) continue;\n    if (attrName === \"path\") {\n      declaration.pathGet = attrTimestamp;\n    } else {\n      attrs.add(attrName);\n      if (!declaration.attrGet) declaration.attrGet = {};\n      declaration.attrGet[attrName] = attrTimestamp;\n    }\n  }\n\n  state.declarations.push(declaration);\n\n  return new ParameterWrapper(parsedPath, attrs);\n}\n\nfunction clear(path: string, timestamp: number, attributes?): void {\n  state.uncommitted = true;\n\n  if (state.revision === state.maxRevision)\n    state.clear.push([Path.parse(path), timestamp, attributes]);\n}\n\nfunction commit(): void {\n  ++state.revision;\n  state.uncommitted = false;\n\n  if (state.revision === state.maxRevision + 1) {\n    for (const d of state.declarations) d.defer = false;\n    throw COMMIT;\n  } else if (state.revision > state.maxRevision + 1) {\n    throw new Error(\n      \"Declare function should not be called from within a try/catch block\",\n    );\n  }\n}\n\nfunction ext(...args: unknown[]): any {\n  ++state.extCounter;\n  const extCall = args.map(String);\n  const key = `${state.revision}: ${JSON.stringify(extCall)}`;\n\n  if (key in state.sessionContext.extensionsCache)\n    return state.sessionContext.extensionsCache[key];\n\n  state.extensions[key] = extCall;\n  throw EXT;\n}\n\nfunction log(msg: string, meta: Record<string, unknown>): void {\n  if (state.revision === state.maxRevision && state.extCounter >= 0) {\n    const details = Object.assign({}, meta, {\n      sessionContext: state.sessionContext,\n      message: `Script: ${msg}`,\n    });\n\n    delete details[\"hostname\"];\n    delete details[\"pid\"];\n    delete details[\"name\"];\n    delete details[\"version\"];\n    delete details[\"deviceId\"];\n    delete details[\"remoteAddress\"];\n\n    logger.accessInfo(details);\n  }\n}\n\nObject.defineProperty(context, \"Date\", { value: SandboxDate });\nObject.defineProperty(context, \"declare\", { value: declare });\nObject.defineProperty(context, \"clear\", { value: clear });\nObject.defineProperty(context, \"commit\", { value: commit });\nObject.defineProperty(context, \"ext\", { value: ext });\nObject.defineProperty(context, \"log\", { value: log });\n\n// Monkey-patch Math.random() to make it deterministic\ncontext.random = random;\nvm.runInContext(\"Math.random = random;\", context);\ndelete context.random;\n\nfunction errorToFault(err: Error): Fault {\n  if (!err) return null;\n\n  if (!err.name) return { code: \"script\", message: `${err}` };\n\n  const fault: Fault = {\n    code: `script.${err.name}`,\n    message: err.message,\n    detail: {\n      name: err.name,\n      message: err.message,\n    },\n  };\n\n  if (err.stack) {\n    fault.detail[\"stack\"] = err.stack;\n    // Trim the stack trace at the self-executing anonymous wrapper function\n    const stackTrimIndex = fault.detail[\"stack\"].match(\n      /\\s+at\\s[^\\s]+\\s+at\\s[^\\s]+\\s\\(vm\\.js.+\\)/,\n    );\n    if (stackTrimIndex) {\n      fault.detail[\"stack\"] = fault.detail[\"stack\"].slice(\n        0,\n        stackTrimIndex.index,\n      );\n    }\n  }\n\n  return fault;\n}\n\nexport async function run(\n  script: vm.Script,\n  globals: Record<string, unknown>,\n  sessionContext: SessionContext,\n  startRevision: number,\n  maxRevision: number,\n  extCounter = 0,\n): Promise<ScriptResult> {\n  state = {\n    sessionContext: sessionContext,\n    revision: startRevision,\n    maxRevision: maxRevision,\n    uncommitted: false,\n    declarations: [],\n    extensions: {},\n    clear: [],\n    rng: null,\n    extCounter: extCounter,\n  };\n\n  for (const n of Object.keys(context)) delete context[n];\n\n  Object.assign(context, globals);\n\n  let ret, status;\n\n  try {\n    ret = script.runInContext(context, { displayErrors: false, timeout: 50 });\n    status = 0;\n  } catch (err) {\n    if (err === COMMIT) {\n      status = 1;\n    } else if (err === EXT) {\n      status = 2;\n    } else {\n      return {\n        fault: errorToFault(err),\n        clear: null,\n        declare: null,\n        done: false,\n        returnValue: null,\n      };\n    }\n  }\n\n  const _state = state;\n  let fault;\n\n  await Promise.all(\n    Object.entries(_state.extensions).map(async ([k, v]) => {\n      fault = (await runExtension(_state.sessionContext, k, v)) || fault;\n    }),\n  );\n\n  if (fault) {\n    return {\n      fault: fault,\n      clear: null,\n      declare: null,\n      done: false,\n      returnValue: null,\n    };\n  }\n\n  if (status === 2) {\n    return run(\n      script,\n      globals,\n      sessionContext,\n      startRevision,\n      maxRevision,\n      extCounter - _state.extCounter,\n    );\n  }\n\n  return {\n    fault: null,\n    clear: _state.clear,\n    declare: _state.declarations,\n    done: status === 0,\n    returnValue: ret,\n  };\n}\n"
  },
  {
    "path": "lib/scheduling.ts",
    "content": "import * as crypto from \"node:crypto\";\nimport * as later from \"@breejs/later\";\n\nfunction md532(str): number {\n  const digest = crypto.createHash(\"md5\").update(str).digest();\n  return (\n    digest.readUInt32LE(0) ^\n    digest.readUInt32LE(4) ^\n    digest.readUInt32LE(8) ^\n    digest.readUInt32LE(12)\n  );\n}\n\nexport function variance(deviceId: string, vrnc: number): number {\n  return (md532(deviceId) >>> 0) % vrnc;\n}\n\nexport function interval(\n  timestamp: number,\n  intrvl: number,\n  offset = 0,\n): number {\n  return Math.trunc((timestamp + offset) / intrvl) * intrvl - offset;\n}\n\nexport function parseCron(cronExp: string): any {\n  const parts = cronExp.trim().split(/\\s+/);\n  if (parts.length === 5) parts.unshift(\"*\");\n\n  return later.schedule(later.parse.cron(parts.join(\" \"), true));\n}\n\nexport function cron(\n  timestamp: number,\n  schedule: unknown,\n  offset = 0,\n): number[] {\n  // TODO later.js doesn't throw erorr if expression is invalid!\n  const ret = [0, 0];\n\n  const prev = (schedule as any).prev(1, new Date(timestamp + offset));\n  if (prev) ret[0] = prev.setMilliseconds(0) - offset;\n\n  const next = (schedule as any).next(1, new Date(timestamp + offset + 1000));\n  if (next) ret[1] = next.setMilliseconds(0) - offset;\n\n  return ret;\n}\n"
  },
  {
    "path": "lib/server.ts",
    "content": "import { readFileSync } from \"node:fs\";\nimport * as http from \"node:http\";\nimport * as https from \"node:https\";\nimport { Socket } from \"node:net\";\nimport * as path from \"node:path\";\nimport { ROOT_DIR } from \"./config.ts\";\n\nlet server: http.Server | https.Server;\nlet listener: http.RequestListener;\nlet stopping = false;\n\nfunction closeServer(timeout, callback): void {\n  if (!server) return void callback();\n\n  setTimeout(() => {\n    if (!callback) return;\n\n    // Ignore HTTP requests from connection that may still be open\n    server.removeListener(\"request\", listener);\n    server.setTimeout(1);\n\n    const cb = callback;\n    callback = null;\n    setTimeout(cb, 1000);\n  }, timeout).unref();\n\n  server.close(() => {\n    if (!callback) return;\n\n    const cb = callback;\n    callback = null;\n    // Allow some time for connection close events to fire\n    setTimeout(cb, 50);\n  });\n}\n\ninterface ServerOptions {\n  port?: number;\n  host?: string;\n  ssl?: { key: string; cert: string };\n  timeout?: number;\n  keepAliveTimeout?: number;\n  requestTimeout?: number;\n  onConnection?: (socket: Socket) => void;\n  onClientError?: (err: Error, socket: Socket) => void;\n}\n\ninterface SocketEndpoint {\n  localAddress: string;\n  localPort: number;\n  remoteAddress: string;\n  remotePort: number;\n  remoteFamily: \"IPv4\" | \"IPv6\";\n}\n\n// Save this info as they're not accessible after a socket has been closed\nconst socketEndpoints: WeakMap<Socket, SocketEndpoint> = new WeakMap();\n\ntype Promisify<T extends (...args: any) => any> = (\n  ...args: Parameters<T>\n) => Promise<ReturnType<T>>;\n\nfunction getValidPrivKeys(value: string): Buffer[] {\n  return value.split(\":\").map((str) => {\n    str = str.trim();\n    const buf = str.startsWith(\"-----BEGIN \")\n      ? Buffer.from(str)\n      : readFileSync(path.resolve(ROOT_DIR, str));\n    return buf;\n  });\n}\n\nfunction getValidCerts(value: string): Buffer[] {\n  return value.split(\":\").map((str) => {\n    str = str.trim();\n    const buf = str.startsWith(\"-----BEGIN \")\n      ? Buffer.from(str)\n      : readFileSync(path.resolve(ROOT_DIR, str));\n    return buf;\n  });\n}\n\nexport function start(\n  options: ServerOptions,\n  _listener: Promisify<http.RequestListener>,\n): void {\n  listener = (req, res) => {\n    if (stopping) res.setHeader(\"Connection\", \"close\");\n    _listener(req, res).catch((err) => {\n      try {\n        res.socket.unref();\n        if (res.headersSent) {\n          res.writeHead(500, { Connection: \"close\" });\n          res.end(`${err.name}: ${err.message}`);\n        }\n      } catch {\n        // Ignore\n      }\n      throw err;\n    });\n  };\n\n  if (options.ssl) {\n    const opts = {\n      key: getValidPrivKeys(options.ssl.key),\n      cert: getValidCerts(options.ssl.cert),\n    };\n\n    server = https.createServer(opts, listener);\n    if (options.onConnection)\n      server.on(\"secureConnection\", options.onConnection);\n  } else {\n    server = http.createServer(listener);\n    if (options.onConnection) server.on(\"connection\", options.onConnection);\n  }\n\n  server.on(\"connection\", (socket: Socket) => {\n    socketEndpoints.set(socket, {\n      localAddress: socket.localAddress,\n      localPort: socket.localPort,\n      remoteAddress: socket.remoteAddress,\n      remotePort: socket.remotePort,\n      remoteFamily: socket.remoteFamily as \"IPv4\" | \"IPv6\",\n    });\n  });\n\n  if (options.onClientError) {\n    server.on(\"clientError\", (err, socket: Socket) => {\n      if (err[\"code\"] !== \"ECONNRESET\" && socket.writable)\n        socket.end(\"HTTP/1.1 400 Bad Request\\r\\nConnection: close\\r\\n\\r\\n\");\n\n      // As per Node docs: This event is guaranteed to be passed an instance\n      // of the <net.Socket> class\n      options.onClientError(err, socket as Socket);\n    });\n  }\n\n  server.timeout = options.timeout || 0;\n  if (options.keepAliveTimeout != null)\n    server.keepAliveTimeout = options.keepAliveTimeout;\n  if (options.requestTimeout != null)\n    server.requestTimeout = options.requestTimeout;\n\n  server.listen({ port: options.port, host: options.host });\n}\n\nexport function stop(terminateConnections = true): Promise<void> {\n  stopping = terminateConnections;\n  return new Promise((resolve, reject) => {\n    setTimeout(() => {\n      reject(new Error(\"Could not close server in a timely manner\"));\n    }, 30000).unref();\n    closeServer(20000, resolve);\n  });\n}\n\nexport function getSocketEndpoints(socket: Socket): SocketEndpoint {\n  // TLSSocket keeps a reference to the raw TCP socket in _parent\n  return socketEndpoints.get(socket[\"_parent\"] ?? socket);\n}\n"
  },
  {
    "path": "lib/session.ts",
    "content": "import * as device from \"./device.ts\";\nimport * as sandbox from \"./sandbox.ts\";\nimport * as localCache from \"./cwmp/local-cache.ts\";\nimport * as defaultProvisions from \"./default-provisions.ts\";\nimport { estimateGpnCount } from \"./gpn-heuristic.ts\";\nimport Path from \"./common/path.ts\";\nimport PathSet from \"./common/path-set.ts\";\nimport VersionedMap from \"./versioned-map.ts\";\nimport InstanceSet from \"./instance-set.ts\";\nimport {\n  Attributes,\n  SessionContext,\n  DeviceData,\n  VirtualParameterDeclaration,\n  AttributeTimestamps,\n  AttributeValues,\n  Fault,\n  Declaration,\n  Clear,\n  CpeResponse,\n  CpeFault,\n  AcsRequest,\n  AcsResponse,\n  InformRequest,\n  TransferCompleteRequest,\n  Operation,\n  ScriptResult,\n  GetParameterValues,\n  GetParameterAttributes,\n  GetParameterNames,\n  SetParameterValues,\n  SetParameterAttributes,\n  AddObject,\n  DeleteObject,\n  Download,\n  Reboot,\n  FactoryReset,\n  AddObjectResponse,\n  GetParameterValuesResponse,\n} from \"./types.ts\";\nimport { getRequestOrigin } from \"./forwarded.ts\";\nimport * as logger from \"./logger.ts\";\nimport { encodeTag } from \"./util.ts\";\nimport Expression, { Value } from \"./common/expression.ts\";\n\nconst VALID_PARAM_TYPES = new Set([\n  \"xsd:int\",\n  \"xsd:unsignedInt\",\n  \"xsd:boolean\",\n  \"xsd:string\",\n  \"xsd:dateTime\",\n  \"xsd:base64\",\n  \"xsd:hexBinary\",\n]);\n\nfunction initDeviceData(): DeviceData {\n  return {\n    paths: new PathSet(),\n    timestamps: new VersionedMap(),\n    attributes: new VersionedMap(),\n    trackers: new Map(),\n    changes: new Set(),\n  };\n}\n\nexport function init(\n  deviceId: string,\n  cwmpVersion: string,\n  timeout: number,\n): SessionContext {\n  const timestamp = Date.now();\n  const sessionContext: SessionContext = {\n    timestamp: timestamp,\n    deviceId: deviceId,\n    deviceData: initDeviceData(),\n    cwmpVersion: cwmpVersion,\n    timeout: timeout,\n    provisions: [],\n    channels: {},\n    virtualParameters: [],\n    revisions: [0],\n    rpcCount: 0,\n    iteration: 0,\n    cycle: 0,\n    extensionsCache: {},\n    declarations: [],\n    state: 0,\n    authState: 0,\n  };\n\n  return sessionContext;\n}\n\nfunction generateRpcId(sessionContext: SessionContext): string {\n  return (\n    sessionContext.timestamp.toString(16) +\n    (\"0\" + sessionContext.cycle.toString(16)).slice(-2) +\n    (\"0\" + sessionContext.rpcCount.toString(16)).slice(-2)\n  );\n}\n\nexport function configContextCallback(\n  sessionContext: SessionContext,\n  exp: Expression,\n): Expression.Literal {\n  if (exp instanceof Expression.Literal) return exp;\n  else if (exp instanceof Expression.FunctionCall) {\n    if (exp.name === \"NOW\")\n      return new Expression.Literal(sessionContext.timestamp);\n    if (exp.name === \"REMOTE_ADDRESS\")\n      return new Expression.Literal(\n        getRequestOrigin(sessionContext.httpRequest).remoteAddress,\n      );\n  } else if (exp instanceof Expression.Parameter) {\n    const deviceData = sessionContext.deviceData;\n    const paths = deviceData.paths;\n    const path = paths.get(exp.path.toString());\n    if (path) {\n      const attrs = deviceData.attributes.get(path, 1);\n      if (attrs?.value?.[1]) return new Expression.Literal(attrs.value[1][0]);\n    }\n  }\n  return new Expression.Literal(null);\n}\n\nexport async function inform(\n  sessionContext: SessionContext,\n  rpcReq: InformRequest,\n): Promise<AcsResponse> {\n  const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;\n  const params: [string, number, Attributes][] = [\n    [\n      \"DeviceID.Manufacturer\",\n      timestamp,\n      {\n        object: [timestamp, 0],\n        writable: [timestamp, 0],\n        value: [timestamp, [rpcReq.deviceId.Manufacturer, \"xsd:string\"]],\n      },\n    ],\n\n    [\n      \"DeviceID.OUI\",\n      timestamp,\n      {\n        object: [timestamp, 0],\n        writable: [timestamp, 0],\n        value: [timestamp, [rpcReq.deviceId.OUI, \"xsd:string\"]],\n      },\n    ],\n\n    [\n      \"DeviceID.ProductClass\",\n      timestamp,\n      {\n        object: [timestamp, 0],\n        writable: [timestamp, 0],\n        value: [timestamp, [rpcReq.deviceId.ProductClass, \"xsd:string\"]],\n      },\n    ],\n\n    [\n      \"DeviceID.SerialNumber\",\n      timestamp,\n      {\n        object: [timestamp, 0],\n        writable: [timestamp, 0],\n        value: [timestamp, [rpcReq.deviceId.SerialNumber, \"xsd:string\"]],\n      },\n    ],\n  ];\n\n  for (const p of rpcReq.parameterList) {\n    const path = p[0];\n    params.push([\n      path.toString(),\n      timestamp,\n      {\n        object: [timestamp, 0],\n        value: [timestamp, p.slice(1) as [string | number | boolean, string]],\n      },\n    ]);\n  }\n\n  params.push([\n    \"Events.Inform\",\n    timestamp,\n    {\n      object: [timestamp, 0],\n      writable: [timestamp, 0],\n      value: [timestamp, [sessionContext.timestamp, \"xsd:dateTime\"]],\n    },\n  ]);\n\n  for (const e of rpcReq.event) {\n    params.push([\n      `Events.${encodeTag(e.replace(/\\s+/g, \"_\"))}`,\n      timestamp,\n      {\n        object: [timestamp, 0],\n        writable: [timestamp, 0],\n        value: [timestamp, [sessionContext.timestamp, \"xsd:dateTime\"]],\n      },\n    ]);\n  }\n\n  if (sessionContext.new) {\n    params.push([\n      \"DeviceID.ID\",\n      timestamp,\n      {\n        object: [timestamp, 0],\n        writable: [timestamp, 0],\n        value: [timestamp, [sessionContext.deviceId, \"xsd:string\"]],\n      },\n    ]);\n    params.push([\n      \"Events.Registered\",\n      timestamp,\n      {\n        object: [timestamp, 0],\n        writable: [timestamp, 0],\n        value: [timestamp, [sessionContext.timestamp, \"xsd:dateTime\"]],\n      },\n    ]);\n  }\n\n  sessionContext.deviceData.timestamps.revision = 1;\n  sessionContext.deviceData.attributes.revision = 1;\n\n  let toClear = null;\n  for (const p of params) {\n    // Don't need to clear wildcards for Events\n    if (p[0].startsWith(\"Events.\")) {\n      device.set(sessionContext.deviceData, p[0], p[1], p[2]);\n    } else {\n      toClear = device.set(\n        sessionContext.deviceData,\n        p[0],\n        p[1],\n        p[2],\n        toClear,\n      );\n    }\n  }\n\n  if (toClear) {\n    for (const c of toClear)\n      device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n  }\n\n  return { name: \"InformResponse\" };\n}\n\nexport async function transferComplete(\n  sessionContext: SessionContext,\n  rpcReq: TransferCompleteRequest,\n): Promise<{ acsResponse: AcsResponse; operation: Operation; fault: Fault }> {\n  const revision =\n    (sessionContext.revisions[sessionContext.revisions.length - 1] || 0) + 1;\n  sessionContext.deviceData.timestamps.revision = revision;\n  sessionContext.deviceData.attributes.revision = revision;\n  const commandKey = rpcReq.commandKey;\n  const operation = sessionContext.operations[commandKey];\n\n  if (!operation) {\n    return {\n      acsResponse: { name: \"TransferCompleteResponse\" },\n      operation: null,\n      fault: null,\n    };\n  }\n\n  const instance = operation.args.instance;\n\n  delete sessionContext.operations[commandKey];\n  if (!sessionContext.operationsTouched) sessionContext.operationsTouched = {};\n  sessionContext.operationsTouched[commandKey] = 1;\n\n  if (rpcReq.faultStruct?.faultCode !== \"0\") {\n    revertDownloadParameters(sessionContext, operation.args.instance);\n\n    const fault: Fault = {\n      code: `cwmp.${rpcReq.faultStruct.faultCode}`,\n      message: rpcReq.faultStruct.faultString,\n      detail: rpcReq.faultStruct,\n      timestamp: operation.timestamp,\n    };\n\n    return {\n      acsResponse: { name: \"TransferCompleteResponse\" },\n      operation: operation,\n      fault: fault,\n    };\n  }\n\n  let toClear = null;\n  const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;\n\n  toClear = device.set(\n    sessionContext.deviceData,\n    `Downloads.${instance}.LastDownload`,\n    timestamp,\n    { value: [timestamp, [operation.timestamp, \"xsd:dateTime\"]] },\n    toClear,\n  );\n\n  toClear = device.set(\n    sessionContext.deviceData,\n    `Downloads.${instance}.LastFileType`,\n    timestamp,\n    { value: [timestamp, [operation.args.fileType, \"xsd:string\"]] },\n    toClear,\n  );\n\n  toClear = device.set(\n    sessionContext.deviceData,\n    `Downloads.${instance}.LastFileName`,\n    timestamp,\n    { value: [timestamp, [operation.args.fileName, \"xsd:string\"]] },\n    toClear,\n  );\n\n  toClear = device.set(\n    sessionContext.deviceData,\n    `Downloads.${instance}.LastTargetFileName`,\n    timestamp,\n    { value: [timestamp, [operation.args.targetFileName, \"xsd:string\"]] },\n    toClear,\n  );\n\n  toClear = device.set(\n    sessionContext.deviceData,\n    `Downloads.${instance}.StartTime`,\n    timestamp,\n    { value: [timestamp, [+rpcReq.startTime, \"xsd:dateTime\"]] },\n    toClear,\n  );\n\n  toClear = device.set(\n    sessionContext.deviceData,\n    `Downloads.${instance}.CompleteTime`,\n    timestamp,\n    { value: [timestamp, [+rpcReq.completeTime, \"xsd:dateTime\"]] },\n    toClear,\n  );\n\n  if (toClear) {\n    for (const c of toClear)\n      device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n  }\n\n  return {\n    acsResponse: { name: \"TransferCompleteResponse\" },\n    operation: operation,\n    fault: null,\n  };\n}\n\nfunction revertDownloadParameters(\n  sessionContext: SessionContext,\n  instance,\n): void {\n  const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;\n\n  const lastDownloadPath = sessionContext.deviceData.paths.add(\n    `Downloads.${instance}.LastDownload`,\n  );\n\n  const lastDownload =\n    sessionContext.deviceData.attributes.get(lastDownloadPath);\n\n  const toClear = device.set(\n    sessionContext.deviceData,\n    `Downloads.${instance}.Download`,\n    timestamp,\n    {\n      value: [timestamp, [lastDownload?.value[1]?.[0] || 0, \"xsd:dateTime\"]],\n    },\n  );\n\n  if (toClear) {\n    for (const c of toClear)\n      device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n  }\n}\n\nexport async function timeoutOperations(\n  sessionContext: SessionContext,\n): Promise<{ faults: Fault[]; operations: Operation[] }> {\n  const revision =\n    (sessionContext.revisions[sessionContext.revisions.length - 1] || 0) + 1;\n  sessionContext.deviceData.timestamps.revision = revision;\n  sessionContext.deviceData.attributes.revision = revision;\n  const faults = [];\n  const operations = [];\n\n  for (const [commandKey, operation] of Object.entries(\n    sessionContext.operations,\n  )) {\n    if (operation.name !== \"Download\")\n      throw new Error(`Unknown operation name ${operation.name}`);\n\n    const DOWNLOAD_TIMEOUT =\n      localCache.getConfig(\n        sessionContext.cacheSnapshot,\n        \"cwmp.downloadTimeout\",\n        3600,\n        (e) => configContextCallback(sessionContext, e),\n      ) * 1000;\n\n    if (sessionContext.timestamp < operation.timestamp + DOWNLOAD_TIMEOUT)\n      continue;\n\n    logger.accessWarn({\n      sessionContext: sessionContext,\n      message: \"Download operation timed out\",\n      commandKey: commandKey,\n    });\n\n    const SUCCESS_ON_TIMEOUT = localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.downloadSuccessOnTimeout\",\n      false,\n      (e) => configContextCallback(sessionContext, e),\n    );\n\n    if (SUCCESS_ON_TIMEOUT) {\n      const r = {\n        name: \"TransferComplete\" as const,\n        commandKey: commandKey,\n        startTime: 0,\n        completeTime: 0,\n      };\n\n      // Call transferComplete code and ignore the response\n      await transferComplete(sessionContext, r);\n      continue;\n    }\n\n    delete sessionContext.operations[commandKey];\n    if (!sessionContext.operationsTouched)\n      sessionContext.operationsTouched = {};\n    sessionContext.operationsTouched[commandKey] = 1;\n\n    faults.push({\n      code: \"timeout\",\n      message: \"Download operation timed out\",\n      timestamp: operation.timestamp,\n    });\n\n    operations.push(operation);\n\n    revertDownloadParameters(sessionContext, operation.args.instance);\n  }\n\n  return { faults, operations };\n}\n\nexport function addProvisions(\n  sessionContext: SessionContext,\n  channel: string,\n  provisions: [string, ...Value[]][],\n): void {\n  // Multiply by two because every iteration is two\n  // phases: read and update\n  const MAX_ITERATIONS =\n    localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.maxCommitIterations\",\n      32,\n      (e) => configContextCallback(sessionContext, e),\n    ) * 2;\n\n  delete sessionContext.syncState;\n  delete sessionContext.rpcRequest;\n  sessionContext.declarations = [];\n  sessionContext.provisionsRet = [];\n\n  if (sessionContext.revisions[sessionContext.revisions.length - 1] > 0) {\n    sessionContext.deviceData.timestamps.collapse(1);\n    sessionContext.deviceData.attributes.collapse(1);\n    sessionContext.revisions = [0];\n    sessionContext.extensionsCache = {};\n  }\n\n  if (sessionContext.iteration !== sessionContext.cycle * MAX_ITERATIONS) {\n    sessionContext.cycle += 1;\n    sessionContext.rpcCount = 0;\n    sessionContext.iteration = sessionContext.cycle * MAX_ITERATIONS;\n  }\n\n  sessionContext.channels[channel] |= 0;\n\n  for (const provision of provisions) {\n    const channels = [channel];\n    // Remove duplicate provisions\n    const provisionStr = JSON.stringify(provision);\n    for (const [j, p] of sessionContext.provisions.entries()) {\n      if (JSON.stringify(p) === provisionStr) {\n        sessionContext.provisions.splice(j, 1);\n        for (const c of Object.keys(sessionContext.channels)) {\n          if (sessionContext.channels[c] & (1 << j)) channels.push(c);\n          const a = sessionContext.channels[c] >> (j + 1);\n          sessionContext.channels[c] &= (1 << j) - 1;\n          sessionContext.channels[c] |= a << j;\n        }\n      }\n    }\n\n    for (const c of channels)\n      sessionContext.channels[c] |= 1 << sessionContext.provisions.length;\n\n    sessionContext.provisions.push(provision);\n  }\n}\n\nexport function clearProvisions(sessionContext: SessionContext): void {\n  // Multiply by two because every iteration is two\n  // phases: read and update\n  const MAX_ITERATIONS =\n    +localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.maxCommitIterations\",\n      32,\n      (e) => configContextCallback(sessionContext, e),\n    ) * 2;\n\n  if (sessionContext.revisions[sessionContext.revisions.length - 1] > 0) {\n    sessionContext.deviceData.timestamps.collapse(1);\n    sessionContext.deviceData.attributes.collapse(1);\n  }\n\n  if (sessionContext.iteration !== sessionContext.cycle * MAX_ITERATIONS) {\n    sessionContext.cycle += 1;\n    sessionContext.rpcCount = 0;\n    sessionContext.iteration = sessionContext.cycle * MAX_ITERATIONS;\n  }\n\n  delete sessionContext.syncState;\n  delete sessionContext.rpcRequest;\n  sessionContext.provisions = [];\n  sessionContext.virtualParameters = [];\n  sessionContext.channels = {};\n  sessionContext.declarations = [];\n  sessionContext.provisionsRet = [];\n  sessionContext.revisions = [0];\n  sessionContext.extensionsCache = {};\n}\n\nasync function runProvisions(\n  sessionContext: SessionContext,\n  provisions: any[][],\n  startRevision: number,\n  endRevision: number,\n): Promise<ScriptResult> {\n  const allProvisions = localCache.getProvisions(sessionContext.cacheSnapshot);\n\n  const res = await Promise.all(\n    provisions.map(async (provision) => {\n      if (!allProvisions[provision[0]]) {\n        if (defaultProvisions[provision[0]]) {\n          const dec = [];\n          let done = true;\n          let fault = null;\n          try {\n            done = defaultProvisions[provision[0]](\n              sessionContext,\n              provision,\n              dec,\n              startRevision,\n              endRevision,\n            );\n          } catch (err) {\n            fault = {\n              code: `script.${err.name}`,\n              message: err.message,\n              detail: {\n                name: err.name,\n                message: err.message,\n                stack: `${err.name}: ${err.message}\\n    at ${provision[0]}`,\n              },\n            };\n          }\n          return {\n            fault: fault,\n            clear: null,\n            declare: dec,\n            done: done,\n            returnValue: null,\n          };\n        }\n        return null;\n      }\n\n      return sandbox.run(\n        allProvisions[provision[0]].script,\n        { args: provision.slice(1) },\n        sessionContext,\n        startRevision,\n        endRevision,\n      );\n    }),\n  );\n\n  let done = true;\n  let allDeclarations = [];\n  let allClear = [];\n  let fault;\n\n  for (const r of res) {\n    if (!r) continue;\n    done = done && r.done;\n    if (r.declare) allDeclarations = allDeclarations.concat(r.declare);\n    if (r.clear) allClear = allClear.concat(r.clear);\n    fault = r.fault || fault;\n  }\n\n  if (done) for (const d of allDeclarations) d.defer = false;\n\n  return {\n    fault: fault,\n    clear: allClear,\n    declare: allDeclarations,\n    done: done,\n    returnValue: null,\n  };\n}\n\nasync function runVirtualParameters(\n  sessionContext: SessionContext,\n  provisions: any[][],\n  startRevision: number,\n  endRevision: number,\n): Promise<ScriptResult> {\n  const allVirtualParameters = localCache.getVirtualParameters(\n    sessionContext.cacheSnapshot,\n  );\n\n  const res = await Promise.all(\n    provisions.map(async (provision) => {\n      const globals = {\n        args: provision.slice(1),\n      };\n\n      const r = await sandbox.run(\n        allVirtualParameters[provision[0]].script,\n        globals,\n        sessionContext,\n        startRevision,\n        endRevision,\n      );\n\n      if (r.done && !r.fault) {\n        if (!r.returnValue) {\n          r.fault = {\n            code: \"script\",\n            message: \"Invalid virtual parameter return value\",\n          };\n          return r;\n        }\n\n        const ret: {\n          writable?: boolean;\n          value?: [string | number | boolean, string?];\n        } = {};\n\n        if (r.returnValue.writable != null) {\n          ret.writable = !!r.returnValue.writable;\n        } else if (\n          provision[1].writable != null ||\n          provision[2].writable != null\n        ) {\n          r.fault = {\n            code: \"script\",\n            message: `Virtual parameter '${provision[0]}' must provide 'writable' attribute`,\n          };\n          return r;\n        }\n\n        if (r.returnValue.value != null) {\n          let v: string | number | boolean, t: string;\n\n          if (Array.isArray(r.returnValue.value)) [v, t] = r.returnValue.value;\n          else v = r.returnValue.value;\n\n          if (!t) {\n            if (typeof v === \"number\") t = \"xsd:int\";\n            else if (typeof v === \"boolean\") t = \"xsd:boolean\";\n            else if ((v as any) instanceof Date) t = \"xsd:datetime\";\n            else t = \"xsd:string\";\n          }\n\n          if (v == null || !VALID_PARAM_TYPES.has(t)) {\n            r.fault = {\n              code: \"script\",\n              message: \"Invalid virtual parameter value attribute\",\n            };\n            return r;\n          }\n\n          ret.value = device.sanitizeParameterValue([v, t]);\n        } else if (provision[1].value != null || provision[2].value != null) {\n          r.fault = {\n            code: \"script\",\n            message: `Virtual parameter '${provision[0]}' must provide 'value' attribute`,\n          };\n          return r;\n        }\n\n        r.returnValue = ret;\n      }\n      return r;\n    }),\n  );\n\n  let done = true;\n  const virtualParameterUpdates = [];\n  let allDeclarations = [];\n  let allClear = [];\n  let fault;\n\n  for (const r of res) {\n    if (!r) {\n      virtualParameterUpdates.push(null);\n      continue;\n    }\n\n    done = done && r.done;\n    if (r.declare) allDeclarations = allDeclarations.concat(r.declare);\n    if (r.clear) allClear = allClear.concat(r.clear);\n    virtualParameterUpdates.push(r.returnValue);\n    fault = r.fault || fault;\n  }\n\n  if (done) for (const d of allDeclarations) d.defer = false;\n\n  return {\n    fault: fault,\n    clear: allClear,\n    declare: allDeclarations,\n    done: done,\n    returnValue: done ? virtualParameterUpdates : null,\n  };\n}\n\nfunction runDeclarations(\n  sessionContext: SessionContext,\n  declarations: Declaration[],\n): VirtualParameterDeclaration[] {\n  if (!sessionContext.syncState) {\n    sessionContext.syncState = {\n      refreshAttributes: {\n        exist: new Set(),\n        object: new Set(),\n        writable: new Set(),\n        value: new Set(),\n        notification: new Set(),\n        accessList: new Set(),\n      },\n      spv: new Map(),\n      spa: new Map(),\n      gpn: new Set<Path>(),\n      gpnPatterns: new Map(),\n      tags: new Map(),\n      virtualParameterDeclarations: [],\n      instancesToDelete: new Map(),\n      instancesToCreate: new Map(),\n      downloadsToDelete: new Set(),\n      downloadsToCreate: new InstanceSet(),\n      downloadsValues: new Map(),\n      downloadsDownload: new Map(),\n      reboot: 0,\n      factoryReset: 0,\n    };\n  }\n\n  const allDeclareTimestamps = new Map<Path, number>();\n  const allDeclareAttributeTimestamps = new Map<Path, AttributeTimestamps>();\n  const allDeclareAttributeValues = new Map<Path, AttributeValues>();\n\n  const allVirtualParameters = localCache.getVirtualParameters(\n    sessionContext.cacheSnapshot,\n  );\n\n  function mergeAttributeTimestamps(p: Path, attrs: AttributeTimestamps): void {\n    let cur = allDeclareAttributeTimestamps.get(p);\n    if (!cur) {\n      allDeclareAttributeTimestamps.set(p, attrs);\n    } else {\n      cur = Object.assign({}, cur);\n      for (const [k, v] of Object.entries(attrs))\n        cur[k] = Math.max(v, cur[k] || 0);\n      allDeclareAttributeTimestamps.set(p, cur);\n    }\n  }\n\n  function mergeAttributeValues(\n    p: Path,\n    attrs: AttributeValues,\n    defer: boolean,\n  ): void {\n    let cur = allDeclareAttributeValues.get(p);\n    if (!cur) {\n      if (!defer) allDeclareAttributeValues.set(p, attrs);\n    } else {\n      cur = Object.assign({}, cur, attrs);\n      allDeclareAttributeValues.set(p, cur);\n    }\n  }\n\n  for (const declaration of declarations) {\n    let path = declaration.path;\n    let unpacked: Path[];\n\n    // Can't run declarations on root\n    if (!path.length) continue;\n\n    if (\n      (path.alias | path.wildcard) & 1 ||\n      path.segments[0] === \"VirtualParameters\"\n    ) {\n      sessionContext.deviceData.paths.add(\"VirtualParameters\");\n      if ((path.alias | path.wildcard) & 2) {\n        sessionContext.deviceData.paths.add(\"VirtualParameters.*\");\n        for (const k of Object.keys(allVirtualParameters)) {\n          sessionContext.deviceData.paths.add(`VirtualParameters.${k}`);\n        }\n      }\n    }\n\n    if ((path.alias | path.wildcard) & 1 || path.segments[0] === \"Reboot\")\n      sessionContext.deviceData.paths.add(\"Reboot\");\n\n    if ((path.alias | path.wildcard) & 1 || path.segments[0] === \"FactoryReset\")\n      sessionContext.deviceData.paths.add(\"FactoryReset\");\n\n    if (path.alias) {\n      const aliasDecs = device.getAliasDeclarations(\n        path,\n        declaration.pathGet || 1,\n      );\n      for (const ad of aliasDecs) {\n        const p = sessionContext.deviceData.paths.add(ad.path.toString());\n        allDeclareTimestamps.set(\n          p,\n          Math.max(ad.pathGet || 1, allDeclareTimestamps.get(p) || 0),\n        );\n        let attrTrackers: string[];\n        if (ad.attrGet) {\n          attrTrackers = Object.keys(ad.attrGet);\n          mergeAttributeTimestamps(p, ad.attrGet);\n        }\n\n        device.track(\n          sessionContext.deviceData,\n          p.toString(),\n          \"prerequisite\",\n          attrTrackers,\n        );\n      }\n\n      unpacked = device.unpack(sessionContext.deviceData, path);\n      for (const u of unpacked) {\n        allDeclareTimestamps.set(\n          u,\n          Math.max(declaration.pathGet || 1, allDeclareTimestamps.get(u) || 0),\n        );\n        if (declaration.attrGet)\n          mergeAttributeTimestamps(u, declaration.attrGet);\n      }\n    } else {\n      path = sessionContext.deviceData.paths.add(path.toString());\n      allDeclareTimestamps.set(\n        path,\n        Math.max(declaration.pathGet || 1, allDeclareTimestamps.get(path) || 0),\n      );\n      if (declaration.attrGet)\n        mergeAttributeTimestamps(path, declaration.attrGet);\n      device.track(sessionContext.deviceData, path.toString(), \"prerequisite\");\n    }\n\n    if (declaration.attrSet) {\n      if (path.alias | path.wildcard) {\n        if (!unpacked)\n          unpacked = device.unpack(sessionContext.deviceData, path);\n\n        for (const u of unpacked) {\n          mergeAttributeValues(u, declaration.attrSet, declaration.defer);\n          // Ensure writable attr is available\n          mergeAttributeTimestamps(u, { writable: 1 });\n        }\n      } else {\n        mergeAttributeValues(path, declaration.attrSet, declaration.defer);\n        // Ensure writable attr is available\n        mergeAttributeTimestamps(path, { writable: 1 });\n      }\n    }\n\n    if (declaration.pathSet != null) {\n      let minInstances: number, maxInstances: number;\n      if (Array.isArray(declaration.pathSet)) {\n        minInstances = declaration.pathSet[0];\n        maxInstances = declaration.pathSet[1];\n      } else {\n        minInstances = maxInstances = declaration.pathSet;\n      }\n\n      let parent = path.slice(0, -1);\n\n      let keys: Record<string, string>;\n      if (path.segments[path.length - 1] instanceof Expression) {\n        keys = {};\n        for (const [p, v] of device.expressionToAlias(\n          path.segments[path.length - 1] as Expression,\n        ))\n          keys[p.toString()] = v as string;\n      } else if (path.segments[path.length - 1] === \"*\") {\n        keys = {};\n      }\n\n      if (!parent.wildcard && !parent.alias) {\n        parent = sessionContext.deviceData.paths.add(parent.toString());\n        if (!unpacked)\n          unpacked = device.unpack(sessionContext.deviceData, path);\n\n        // Ensure writable attr is available\n        mergeAttributeTimestamps(parent, { writable: 1 });\n        for (const u of unpacked) mergeAttributeTimestamps(u, { writable: 1 });\n\n        processInstances(\n          sessionContext,\n          parent,\n          unpacked,\n          keys,\n          minInstances,\n          maxInstances,\n          declaration.defer,\n        );\n      } else {\n        const parentsUnpacked = device.unpack(\n          sessionContext.deviceData,\n          parent,\n        );\n        for (const par of parentsUnpacked) {\n          const up = device.unpack(\n            sessionContext.deviceData,\n            par.concat(path.slice(-1)),\n          );\n\n          // Ensure writable attr is available\n          mergeAttributeTimestamps(par, { writable: 1 });\n          for (const u of up) mergeAttributeTimestamps(u, { writable: 1 });\n\n          processInstances(\n            sessionContext,\n            par,\n            up,\n            keys,\n            minInstances,\n            maxInstances,\n            declaration.defer,\n          );\n        }\n      }\n    }\n  }\n\n  return processDeclarations(\n    sessionContext,\n    allDeclareTimestamps,\n    allDeclareAttributeTimestamps,\n    allDeclareAttributeValues,\n  );\n}\n\nexport async function rpcRequest(\n  sessionContext: SessionContext,\n  _declarations: Declaration[],\n): Promise<{ fault: Fault; rpcId: string; rpc: AcsRequest }> {\n  if (sessionContext.rpcRequest != null) {\n    return {\n      fault: null,\n      rpcId: generateRpcId(sessionContext),\n      rpc: sessionContext.rpcRequest,\n    };\n  }\n\n  if (\n    !sessionContext.virtualParameters.length &&\n    !sessionContext.declarations.length &&\n    !_declarations?.length &&\n    !sessionContext.provisions.length\n  )\n    return { fault: null, rpcId: null, rpc: null };\n\n  if (\n    sessionContext.declarations.length <=\n    sessionContext.virtualParameters.length\n  ) {\n    const inception = sessionContext.declarations.length;\n    const revision = (sessionContext.revisions[inception] || 0) + 1;\n    sessionContext.deviceData.timestamps.revision = revision;\n    sessionContext.deviceData.attributes.revision = revision;\n\n    let run: typeof runProvisions, provisions;\n    if (inception === 0) {\n      run = runProvisions;\n      provisions = sessionContext.provisions;\n    } else {\n      run = runVirtualParameters;\n      provisions = sessionContext.virtualParameters[inception - 1];\n    }\n\n    const {\n      fault,\n      clear: toClear,\n      declare: decs,\n      done: done,\n      returnValue: ret,\n    } = await run(\n      sessionContext,\n      provisions,\n      sessionContext.revisions[inception - 1] || 0,\n      sessionContext.revisions[inception],\n    );\n\n    if (fault) {\n      fault.timestamp = sessionContext.timestamp;\n      return { fault: fault, rpcId: null, rpc: null };\n    }\n\n    // Enforce max clear timestamp\n    for (const c of toClear) {\n      if (c[1] > sessionContext.timestamp) c[1] = sessionContext.timestamp;\n\n      if (c[2]) {\n        for (const [k, v] of Object.entries(c[2]))\n          if (v > sessionContext.timestamp) c[2][k] = sessionContext.timestamp;\n      }\n    }\n\n    sessionContext.declarations.push(decs);\n    sessionContext.provisionsRet[inception] = inception ? ret : done;\n\n    for (const d of decs) {\n      // Enforce max timestamp\n      if (d.pathGet > sessionContext.timestamp)\n        d.pathGet = sessionContext.timestamp;\n\n      if (d.attrGet) {\n        for (const [k, v] of Object.entries(d.attrGet)) {\n          if (v > sessionContext.timestamp)\n            d.attrGet[k] = sessionContext.timestamp;\n        }\n      }\n    }\n\n    if (toClear) {\n      for (const c of toClear)\n        device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n    }\n\n    return rpcRequest(sessionContext, _declarations);\n  }\n\n  if (_declarations?.length) {\n    delete sessionContext.syncState;\n    if (!sessionContext.declarations[0]) sessionContext.declarations[0] = [];\n    sessionContext.declarations[0] =\n      sessionContext.declarations[0].concat(_declarations);\n    return rpcRequest(sessionContext, null);\n  }\n\n  if (sessionContext.rpcCount >= 255) {\n    return {\n      fault: {\n        code: \"too_many_rpcs\",\n        message: \"Too many RPC requests\",\n        timestamp: sessionContext.timestamp,\n      },\n      rpcId: null,\n      rpc: null,\n    };\n  }\n\n  if (sessionContext.revisions.length >= 8) {\n    return {\n      fault: {\n        code: \"deeply_nested_vparams\",\n        message:\n          \"Virtual parameters are referencing other virtual parameters in a deeply nested manner\",\n        timestamp: sessionContext.timestamp,\n      },\n      rpcId: null,\n      rpc: null,\n    };\n  }\n\n  if (sessionContext.cycle >= 255) {\n    return {\n      fault: {\n        code: \"too_many_cycles\",\n        message: \"Too many provision cycles\",\n        timestamp: sessionContext.timestamp,\n      },\n      rpcId: null,\n      rpc: null,\n    };\n  }\n\n  // Multiply by two because every iteration is two\n  // phases: read and update\n  const MAX_ITERATIONS =\n    +localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.maxCommitIterations\",\n      32,\n      (e) => configContextCallback(sessionContext, e),\n    ) * 2;\n\n  if (sessionContext.iteration >= MAX_ITERATIONS * (sessionContext.cycle + 1)) {\n    return {\n      fault: {\n        code: \"too_many_commits\",\n        message: \"Too many commit iterations\",\n        timestamp: sessionContext.timestamp,\n      },\n      rpcId: null,\n      rpc: null,\n    };\n  }\n\n  if (\n    !(\n      sessionContext.syncState &&\n      sessionContext.syncState.virtualParameterDeclarations &&\n      sessionContext.syncState.virtualParameterDeclarations.length >=\n        sessionContext.declarations.length\n    )\n  ) {\n    const inception =\n      sessionContext.syncState &&\n      sessionContext.syncState.virtualParameterDeclarations\n        ? sessionContext.syncState.virtualParameterDeclarations.length\n        : 0;\n\n    // Avoid unnecessary increment of iteration when using vparams\n    if (inception === sessionContext.declarations.length - 1)\n      sessionContext.iteration += 2;\n\n    let vpd = runDeclarations(\n      sessionContext,\n      sessionContext.declarations[inception],\n    );\n    const timestamp = sessionContext.timestamp + sessionContext.iteration;\n\n    let toClear: Clear[];\n\n    const allVirtualParameters = localCache.getVirtualParameters(\n      sessionContext.cacheSnapshot,\n    );\n\n    vpd = vpd.filter((declaration) => {\n      if (Object.keys(allVirtualParameters).length) {\n        if (declaration[0].length === 1) {\n          // Avoid setting on every inform as \"exist\" timestamp\n          // is not saved in DB\n          if (!sessionContext.deviceData.attributes.has(declaration[0])) {\n            toClear = device.set(\n              sessionContext.deviceData,\n              declaration[0].toString(),\n              timestamp,\n              { object: [timestamp, 1], writable: [timestamp, 0] },\n              toClear,\n            );\n          }\n\n          return false;\n        } else if (declaration[0].length === 2) {\n          if (declaration[0].segments[1] === \"*\") {\n            for (const k of Object.keys(allVirtualParameters)) {\n              toClear = device.set(\n                sessionContext.deviceData,\n                `VirtualParameters.${k}`,\n                timestamp,\n                {\n                  object: [timestamp, 0],\n                },\n                toClear,\n              );\n            }\n            toClear = device.set(\n              sessionContext.deviceData,\n              declaration[0].toString(),\n              timestamp,\n              null,\n              toClear,\n            );\n            return false;\n          } else if (\n            allVirtualParameters[declaration[0].segments[1] as string]\n          ) {\n            // Avoid setting on every inform as \"exist\" timestamp\n            // is not saved in DB\n            if (!sessionContext.deviceData.attributes.has(declaration[0])) {\n              toClear = device.set(\n                sessionContext.deviceData,\n                declaration[0].toString(),\n                timestamp,\n                { object: [timestamp, 0] },\n                toClear,\n              );\n            }\n\n            return true;\n          }\n        }\n      }\n\n      for (const p of sessionContext.deviceData.paths.findCompat(\n        declaration[0],\n        false,\n        true,\n      )) {\n        if (sessionContext.deviceData.attributes.has(p)) {\n          if (!toClear) toClear = [];\n          toClear.push([declaration[0], timestamp]);\n          break;\n        }\n      }\n      return false;\n    });\n\n    if (toClear) {\n      for (const c of toClear)\n        device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n    }\n\n    sessionContext.syncState.virtualParameterDeclarations[inception] = vpd;\n    return rpcRequest(sessionContext, null);\n  }\n\n  if (!sessionContext.syncState) return { fault: null, rpcId: null, rpc: null };\n\n  const inception = sessionContext.declarations.length - 1;\n\n  let provisions = generateGetVirtualParameterProvisions(\n    sessionContext,\n    sessionContext.syncState.virtualParameterDeclarations[inception],\n  );\n\n  if (!provisions) {\n    sessionContext.rpcRequest = generateGetRpcRequest(sessionContext);\n    if (!sessionContext.rpcRequest) {\n      // Only check after read stage is complete to minimize reprocessing of\n      // declarations especially during initial discovery of data model\n      if (sessionContext.deviceData.changes.has(\"prerequisite\")) {\n        delete sessionContext.syncState;\n        device.clearTrackers(sessionContext.deviceData, \"prerequisite\");\n        return rpcRequest(sessionContext, null);\n      }\n\n      let toClear: Clear[];\n      const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;\n\n      // Update tags\n      for (const [p, v] of sessionContext.syncState.tags) {\n        const c = sessionContext.deviceData.attributes.get(p);\n        if (v && !c) {\n          toClear = device.set(\n            sessionContext.deviceData,\n            p.toString(),\n            timestamp,\n            {\n              object: [timestamp, 0],\n              writable: [timestamp, 1],\n              value: [timestamp, [true, \"xsd:boolean\"]],\n            },\n            toClear,\n          );\n        } else if (c && !v) {\n          toClear = device.set(\n            sessionContext.deviceData,\n            p.toString(),\n            timestamp,\n            null,\n            toClear,\n          );\n        }\n      }\n\n      // Downloads\n      let index: number;\n      for (const instance of sessionContext.syncState.downloadsToCreate) {\n        if (index == null) {\n          index = 0;\n          for (const p of sessionContext.deviceData.paths.findCompat(\n            Path.parse(\"Downloads.*\"),\n            false,\n            true,\n          )) {\n            if (\n              +p.segments[1] > index &&\n              sessionContext.deviceData.attributes.has(p)\n            )\n              index = +p.segments[1];\n          }\n        }\n\n        ++index;\n\n        toClear = device.set(\n          sessionContext.deviceData,\n          \"Downloads\",\n          timestamp,\n          { object: [timestamp, 1], writable: [timestamp, 1] },\n          toClear,\n        );\n\n        toClear = device.set(\n          sessionContext.deviceData,\n          `Downloads.${index}`,\n          timestamp,\n          { object: [timestamp, 1], writable: [timestamp, 1] },\n          toClear,\n        );\n\n        const params = {\n          FileType: {\n            writable: 1,\n            value: [instance.FileType || \"\", \"xsd:string\"],\n          },\n          FileName: {\n            writable: 1,\n            value: [instance.FileName || \"\", \"xsd:string\"],\n          },\n          TargetFileName: {\n            writable: 1,\n            value: [instance.TargetFileName || \"\", \"xsd:string\"],\n          },\n          Download: {\n            writable: 1,\n            value: [instance.Download || 0, \"xsd:dateTime\"],\n          },\n          LastFileType: { writable: 0, value: [\"\", \"xsd:string\"] },\n          LastFileName: { writable: 0, value: [\"\", \"xsd:string\"] },\n          LastTargetFileName: { writable: 0, value: [\"\", \"xsd:string\"] },\n          LastDownload: { writable: 0, value: [0, \"xsd:dateTime\"] },\n          StartTime: { writable: 0, value: [0, \"xsd:dateTime\"] },\n          CompleteTime: { writable: 0, value: [0, \"xsd:dateTime\"] },\n        };\n\n        for (const [k, v] of Object.entries(params)) {\n          toClear = device.set(\n            sessionContext.deviceData,\n            `Downloads.${index}.${k}`,\n            timestamp,\n            {\n              object: [timestamp, 0],\n              writable: [timestamp, v.writable as 0 | 1],\n              value: [\n                timestamp,\n                v.value as [string | number | boolean, string],\n              ],\n            },\n            toClear,\n          );\n        }\n\n        toClear = device.set(\n          sessionContext.deviceData,\n          `Downloads.${index}.*`,\n          timestamp,\n          null,\n          toClear,\n        );\n      }\n\n      sessionContext.syncState.downloadsToCreate.clear();\n\n      for (const instance of sessionContext.syncState.downloadsToDelete) {\n        toClear = device.set(\n          sessionContext.deviceData,\n          instance.toString(),\n          timestamp,\n          null,\n          toClear,\n        );\n        for (const p of sessionContext.syncState.downloadsValues.keys()) {\n          if (p.segments[1] === instance.segments[1])\n            sessionContext.syncState.downloadsValues.delete(p);\n        }\n      }\n\n      sessionContext.syncState.downloadsToDelete.clear();\n\n      for (const [p, v] of sessionContext.syncState.downloadsValues) {\n        const attrs = sessionContext.deviceData.attributes.get(p);\n        if (attrs) {\n          if (attrs.writable?.[1] && attrs.value) {\n            const val = device.sanitizeParameterValue([v, attrs.value[1][1]]);\n            if (val[0] !== attrs.value[1][0]) {\n              toClear = device.set(\n                sessionContext.deviceData,\n                p.toString(),\n                timestamp,\n                { value: [timestamp, val] },\n                toClear,\n              );\n            }\n          }\n        }\n      }\n\n      if (toClear || sessionContext.deviceData.changes.has(\"prerequisite\")) {\n        if (toClear) {\n          for (const c of toClear)\n            device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n        }\n        return rpcRequest(sessionContext, null);\n      }\n\n      provisions = generateSetVirtualParameterProvisions(\n        sessionContext,\n        sessionContext.syncState.virtualParameterDeclarations[inception],\n      );\n      if (!provisions)\n        sessionContext.rpcRequest = generateSetRpcRequest(sessionContext);\n    }\n  }\n\n  if (provisions) {\n    sessionContext.virtualParameters.push(provisions);\n    sessionContext.revisions.push(sessionContext.revisions[inception]);\n    return rpcRequest(sessionContext, null);\n  }\n\n  if (sessionContext.rpcRequest) {\n    return {\n      fault: null,\n      rpcId: generateRpcId(sessionContext),\n      rpc: sessionContext.rpcRequest,\n    };\n  }\n\n  ++sessionContext.revisions[inception];\n  sessionContext.declarations.pop();\n  sessionContext.syncState.virtualParameterDeclarations.pop();\n\n  const ret = sessionContext.provisionsRet.splice(inception)[0];\n  if (!ret) return rpcRequest(sessionContext, null);\n\n  sessionContext.revisions.pop();\n  const rev =\n    sessionContext.revisions[sessionContext.revisions.length - 1] || 0;\n  sessionContext.deviceData.timestamps.collapse(rev + 1);\n  sessionContext.deviceData.attributes.collapse(rev + 1);\n  sessionContext.deviceData.timestamps.revision = rev + 1;\n  sessionContext.deviceData.attributes.revision = rev + 1;\n\n  for (const k of Object.keys(sessionContext.extensionsCache)) {\n    if (rev < Number(k.split(\":\", 1)[0]))\n      delete sessionContext.extensionsCache[k];\n  }\n\n  const vparams = sessionContext.virtualParameters.pop();\n  if (!vparams) return { fault: null, rpcId: null, rpc: null };\n\n  const timestamp = sessionContext.timestamp + sessionContext.iteration;\n  let toClear;\n  for (const [i, vpu] of ret.entries()) {\n    for (const [k, v] of Object.entries(vpu))\n      vpu[k] = [timestamp + (vparams[i][2][k] != null ? 1 : 0), v];\n\n    toClear = device.set(\n      sessionContext.deviceData,\n      `VirtualParameters.${vparams[i][0]}`,\n      timestamp,\n      vpu,\n      toClear,\n    );\n  }\n\n  if (toClear) {\n    for (const c of toClear)\n      device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n  }\n\n  return rpcRequest(sessionContext, null);\n}\n\nfunction generateGetRpcRequest(\n  sessionContext: SessionContext,\n): GetParameterNames | GetParameterValues | GetParameterAttributes {\n  const syncState = sessionContext.syncState;\n  if (!syncState) return null;\n\n  for (const path of syncState.refreshAttributes.exist) {\n    let found = false;\n    for (const p of sessionContext.deviceData.paths.findCompat(\n      path,\n      false,\n      true,\n      99,\n    )) {\n      if (\n        syncState.refreshAttributes.value.has(p) ||\n        syncState.refreshAttributes.object.has(p) ||\n        syncState.refreshAttributes.writable.has(p) ||\n        syncState.refreshAttributes.notification.has(p) ||\n        syncState.refreshAttributes.accessList.has(p) ||\n        syncState.gpn.has(p)\n      ) {\n        found = true;\n        break;\n      }\n    }\n\n    if (!found) {\n      const p = sessionContext.deviceData.paths.add(\n        path.slice(0, -1).toString(),\n      );\n      syncState.gpn.add(p);\n      const f = 1 << p.length;\n      syncState.gpnPatterns.set(p, f | syncState.gpnPatterns.get(p));\n    }\n  }\n  syncState.refreshAttributes.exist.clear();\n\n  for (const path of syncState.refreshAttributes.object) {\n    let found = false;\n    for (const p of sessionContext.deviceData.paths.findCompat(\n      path,\n      false,\n      true,\n      99,\n    )) {\n      if (\n        syncState.refreshAttributes.value.has(p) ||\n        (p.length > path.length &&\n          (syncState.refreshAttributes.object.has(p) ||\n            syncState.refreshAttributes.writable.has(p) ||\n            syncState.refreshAttributes.notification.has(p) ||\n            syncState.refreshAttributes.accessList.has(p)))\n      ) {\n        found = true;\n        break;\n      }\n    }\n\n    if (!found) {\n      const p = sessionContext.deviceData.paths.add(\n        path.slice(0, -1).toString(),\n      );\n      syncState.gpn.add(p);\n      const f = 1 << p.length;\n      syncState.gpnPatterns.set(p, f | syncState.gpnPatterns.get(p));\n    }\n  }\n  syncState.refreshAttributes.object.clear();\n\n  for (const path of syncState.refreshAttributes.writable) {\n    const p = sessionContext.deviceData.paths.add(path.slice(0, -1).toString());\n    syncState.gpn.add(p);\n    const f = 1 << p.length;\n    syncState.gpnPatterns.set(p, f | syncState.gpnPatterns.get(p));\n  }\n  syncState.refreshAttributes.writable.clear();\n\n  if (syncState.gpn.size) {\n    const GPN_NEXT_LEVEL = localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.gpnNextLevel\",\n      0,\n      (e) => configContextCallback(sessionContext, e),\n    ) as number;\n\n    const paths = Array.from(syncState.gpn.keys()).sort(\n      (a, b) => b.length - a.length,\n    );\n    let path = paths.pop();\n\n    // Skip root GPN workaround\n    if (path && !path.length) {\n      const SKIP_ROOT_GPN = localCache.getConfig(\n        sessionContext.cacheSnapshot,\n        \"cwmp.skipRootGpn\",\n        false,\n        (e) => configContextCallback(sessionContext, e),\n      );\n\n      if (SKIP_ROOT_GPN) path = paths.pop();\n    }\n\n    while (\n      path &&\n      path.length &&\n      !sessionContext.deviceData.attributes.has(path)\n    ) {\n      syncState.gpn.delete(path);\n      path = paths.pop();\n    }\n\n    if (path) {\n      let nextLevel: boolean;\n      let est = 0;\n      if (path.length >= GPN_NEXT_LEVEL) {\n        const patterns: [Path, number][] = [[path, 0]];\n        for (const p of sessionContext.deviceData.paths.findCompat(\n          path,\n          true,\n          false,\n          99,\n        )) {\n          const v = syncState.gpnPatterns.get(p);\n          if (v) patterns.push([p, (v >> path.length) << path.length]);\n        }\n        est = estimateGpnCount(patterns);\n      }\n\n      if (est < Math.pow(2, Math.max(0, 8 - path.length))) {\n        nextLevel = true;\n        syncState.gpn.delete(path);\n      } else {\n        nextLevel = false;\n        for (const p of sessionContext.deviceData.paths.findCompat(\n          path,\n          false,\n          true,\n          99,\n        ))\n          syncState.gpn.delete(p);\n      }\n\n      return {\n        name: \"GetParameterNames\",\n        parameterPath: path.length ? path.toString() + \".\" : \"\",\n        nextLevel: nextLevel,\n      };\n    }\n  }\n\n  if (syncState.refreshAttributes.value.size) {\n    const GPV_BATCH_SIZE = localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.gpvBatchSize\",\n      32,\n      (e) => configContextCallback(sessionContext, e),\n    ) as number;\n\n    const parameterNames: string[] = [];\n    for (const path of syncState.refreshAttributes.value) {\n      syncState.refreshAttributes.value.delete(path);\n      // Need to check in case param is deleted or changed to object\n      const attrs = sessionContext.deviceData.attributes.get(path);\n      if (attrs?.object?.[1] === 0) {\n        parameterNames.push(path.toString());\n        if (parameterNames.length >= GPV_BATCH_SIZE) break;\n      }\n    }\n\n    if (parameterNames.length) {\n      return {\n        name: \"GetParameterValues\",\n        parameterNames: parameterNames,\n      };\n    }\n  }\n\n  if (\n    syncState.refreshAttributes.notification.size ||\n    syncState.refreshAttributes.accessList.size\n  ) {\n    const GPV_BATCH_SIZE = localCache.getConfig(\n      sessionContext.cacheSnapshot,\n      \"cwmp.gpvBatchSize\",\n      32,\n      (e) => configContextCallback(sessionContext, e),\n    ) as number;\n\n    const parameterNames: string[] = [];\n    for (const path of syncState.refreshAttributes.notification) {\n      syncState.refreshAttributes.notification.delete(path);\n      syncState.refreshAttributes.accessList.delete(path);\n      // Need to check in case param is deleted\n      const attrs = sessionContext.deviceData.attributes.get(path);\n      if (attrs) {\n        parameterNames.push(path.toString());\n        if (parameterNames.length >= GPV_BATCH_SIZE) break;\n      }\n    }\n\n    if (parameterNames.length < GPV_BATCH_SIZE) {\n      for (const path of syncState.refreshAttributes.accessList) {\n        syncState.refreshAttributes.accessList.delete(path);\n        const attrs = sessionContext.deviceData.attributes.get(path);\n        if (attrs) {\n          parameterNames.push(path.toString());\n          if (parameterNames.length >= GPV_BATCH_SIZE) break;\n        }\n      }\n    }\n\n    if (parameterNames.length) {\n      return {\n        name: \"GetParameterAttributes\",\n        parameterNames: parameterNames,\n      };\n    }\n  }\n\n  return null;\n}\n\nfunction compareAccessLists(list1: string[], list2: string[]): boolean {\n  if (list1.length !== list2.length) return false;\n  for (const [i, v] of list1.entries()) if (v !== list2[i]) return false;\n  return true;\n}\n\nfunction generateSetRpcRequest(\n  sessionContext: SessionContext,\n): (\n  | SetParameterValues\n  | SetParameterAttributes\n  | AddObject\n  | DeleteObject\n  | FactoryReset\n  | Reboot\n  | Download\n) & { next?: string } {\n  const syncState = sessionContext.syncState;\n  if (!syncState) return null;\n\n  const deviceData = sessionContext.deviceData;\n\n  const SKIP_WRITABLE_CHECK = localCache.getConfig(\n    sessionContext.cacheSnapshot,\n    \"cwmp.skipWritableCheck\",\n    false,\n    (e) => configContextCallback(sessionContext, e),\n  );\n\n  const canWrite = (attrs: Attributes): boolean =>\n    SKIP_WRITABLE_CHECK || (attrs.writable && !!attrs.writable[1]);\n\n  // Delete instance\n  for (const instances of syncState.instancesToDelete.values()) {\n    for (const instance of instances) {\n      const attrs = sessionContext.deviceData.attributes.get(instance);\n      if (attrs && canWrite(attrs)) {\n        instances.delete(instance);\n        return {\n          name: \"DeleteObject\",\n          objectName: instance.toString() + \".\",\n        };\n      }\n    }\n  }\n\n  // Create instance\n  for (const [param, instances] of syncState.instancesToCreate) {\n    const attrs = sessionContext.deviceData.attributes.get(param);\n    if (attrs && canWrite(attrs)) {\n      const instance = instances.values().next().value;\n      if (instance) {\n        instances.delete(instance);\n        return {\n          name: \"AddObject\",\n          objectName: param.toString() + \".\",\n          instanceValues: instance,\n          next: \"getInstanceKeys\",\n        };\n      }\n    }\n  }\n\n  // Set values\n  const GPV_BATCH_SIZE = localCache.getConfig(\n    sessionContext.cacheSnapshot,\n    \"cwmp.gpvBatchSize\",\n    32,\n    (e) => configContextCallback(sessionContext, e),\n  ) as number;\n\n  const DATETIME_MILLISECONDS = localCache.getConfig(\n    sessionContext.cacheSnapshot,\n    \"cwmp.datetimeMilliseconds\",\n    true,\n    (e) => configContextCallback(sessionContext, e),\n  );\n\n  const BOOLEAN_LITERAL = localCache.getConfig(\n    sessionContext.cacheSnapshot,\n    \"cwmp.booleanLiteral\",\n    true,\n    (e) => configContextCallback(sessionContext, e),\n  );\n\n  const parameterValues: [string, string | number | boolean, string][] = [];\n  for (const [k, v] of syncState.spv) {\n    syncState.spv.delete(k);\n    const attrs = sessionContext.deviceData.attributes.get(k);\n    const curVal = attrs.value?.[1];\n    if (curVal && canWrite(attrs)) {\n      const val = v.slice() as [string | number | boolean, string];\n      if (!val[1]) val[1] = curVal[1];\n      device.sanitizeParameterValue(val);\n\n      // Strip milliseconds\n      if (\n        val[1] === \"xsd:dateTime\" &&\n        !DATETIME_MILLISECONDS &&\n        typeof val[0] === \"number\"\n      )\n        val[0] -= val[0] % 1000;\n\n      if (val[0] !== curVal[0] || val[1] !== curVal[1])\n        parameterValues.push([k.toString(), val[0], val[1]]);\n\n      if (parameterValues.length >= GPV_BATCH_SIZE) break;\n    }\n  }\n\n  if (parameterValues.length) {\n    return {\n      name: \"SetParameterValues\",\n      parameterList: parameterValues,\n      DATETIME_MILLISECONDS: DATETIME_MILLISECONDS,\n      BOOLEAN_LITERAL: BOOLEAN_LITERAL,\n    };\n  }\n\n  // Set attributes\n  const parameterAttributes: [string, number, string[]][] = [];\n  for (const [k, v] of syncState.spa) {\n    syncState.spa.delete(k);\n    const attrs = sessionContext.deviceData.attributes.get(k);\n\n    if (\n      v.notification != null &&\n      (!attrs.notification || v.notification === attrs.notification[1])\n    )\n      v.notification = null;\n\n    if (\n      v.accessList != null &&\n      (!attrs.accessList ||\n        compareAccessLists(v.accessList, attrs.accessList[1]))\n    )\n      v.accessList = null;\n\n    if (v.notification != null || v.accessList != null)\n      parameterAttributes.push([k.toString(), v.notification, v.accessList]);\n\n    if (parameterAttributes.length >= GPV_BATCH_SIZE) break;\n  }\n\n  if (parameterAttributes.length) {\n    return {\n      name: \"SetParameterAttributes\",\n      parameterList: parameterAttributes,\n    };\n  }\n\n  // Downloads\n  for (const [p, t] of syncState.downloadsDownload) {\n    if (!(t > 0 && t <= sessionContext.timestamp)) continue;\n    const attrs = deviceData.attributes.get(p);\n    const t2 = attrs?.value?.[1]?.[0] as number;\n    if (!(t <= t2)) {\n      const fileTypeAttrs = deviceData.attributes.get(\n        deviceData.paths.get(p.slice(0, -1).toString() + \".FileType\"),\n      );\n      const fileNameAttrs = deviceData.attributes.get(\n        deviceData.paths.get(p.slice(0, -1).toString() + \".FileName\"),\n      );\n      const targetFileNameAttrs = deviceData.attributes.get(\n        deviceData.paths.get(p.slice(0, -1).toString() + \".TargetFileName\"),\n      );\n\n      return {\n        name: \"Download\",\n        commandKey: generateRpcId(sessionContext),\n        instance: p.segments[1] as string,\n        fileType: fileTypeAttrs?.value?.[1][0] as string,\n        fileName: fileNameAttrs?.value?.[1][0] as string,\n        targetFileName: targetFileNameAttrs?.value?.[1][0] as string,\n      };\n    }\n  }\n\n  // Reboot\n  if (syncState.reboot > 0 && syncState.reboot <= sessionContext.timestamp) {\n    const p = sessionContext.deviceData.paths.get(\"Reboot\");\n    const attrs = p ? sessionContext.deviceData.attributes.get(p) : null;\n    const t = attrs?.value?.[1][0] as number;\n    if (!(t >= syncState.reboot)) {\n      delete syncState.reboot;\n      return { name: \"Reboot\" };\n    }\n  }\n\n  // Factory reset\n  if (\n    syncState.factoryReset > 0 &&\n    syncState.factoryReset <= sessionContext.timestamp\n  ) {\n    const p = sessionContext.deviceData.paths.get(\"FactoryReset\");\n    const attrs = p ? sessionContext.deviceData.attributes.get(p) : null;\n    const t = attrs?.value?.[1][0] as number;\n    if (!(t >= syncState.factoryReset)) {\n      delete syncState.factoryReset;\n      return { name: \"FactoryReset\" };\n    }\n  }\n\n  return null;\n}\n\nfunction generateGetVirtualParameterProvisions(\n  sessionContext: SessionContext,\n  virtualParameterDeclarations: VirtualParameterDeclaration[],\n): [\n  string,\n  AttributeTimestamps,\n  AttributeValues,\n  AttributeTimestamps,\n  AttributeValues,\n][] {\n  let provisions;\n  if (virtualParameterDeclarations) {\n    for (const declaration of virtualParameterDeclarations) {\n      if (declaration[1]) {\n        const currentTimestamps = {};\n        const currentValues = {};\n        const dec = {};\n        const attrs =\n          sessionContext.deviceData.attributes.get(declaration[0]) || {};\n\n        for (const [k, v] of Object.entries(declaration[1])) {\n          if (k !== \"value\" && k !== \"writable\") continue;\n          if (!attrs[k] || v > attrs[k][0]) dec[k] = v;\n        }\n\n        for (const [k, v] of Object.entries(attrs)) {\n          currentTimestamps[k] = v[0];\n          currentValues[k] = v[1];\n        }\n\n        if (Object.keys(dec).length) {\n          if (!provisions) provisions = [];\n          provisions.push([\n            declaration[0].segments[1],\n            dec,\n            {},\n            currentTimestamps,\n            currentValues,\n          ]);\n        }\n      }\n    }\n  }\n  return provisions;\n}\n\nfunction generateSetVirtualParameterProvisions(\n  sessionContext: SessionContext,\n  virtualParameterDeclarations: VirtualParameterDeclaration[],\n): [\n  string,\n  AttributeTimestamps,\n  AttributeValues,\n  AttributeTimestamps,\n  AttributeValues,\n][] {\n  let provisions;\n  if (virtualParameterDeclarations) {\n    for (const declaration of virtualParameterDeclarations) {\n      if (declaration[2]?.value != null) {\n        const attrs = sessionContext.deviceData.attributes.get(declaration[0]);\n        if (\n          attrs &&\n          attrs.writable &&\n          attrs.writable[1] &&\n          attrs.value &&\n          attrs.value[1] != null\n        ) {\n          const val = declaration[2].value.slice() as [\n            string | number | boolean,\n            string,\n          ];\n          if (val[1] == null) val[1] = attrs.value[1][1];\n\n          device.sanitizeParameterValue(val);\n\n          if (val[0] !== attrs.value[1][0] || val[1] !== attrs.value[1][1]) {\n            if (!provisions) provisions = [];\n            const currentTimestamps = {};\n            const currentValues = {};\n            for (const [k, v] of Object.entries(attrs)) {\n              currentTimestamps[k] = v[0];\n              currentValues[k] = v[1];\n            }\n\n            provisions.push([\n              declaration[0].segments[1],\n              {},\n              { value: val },\n              currentTimestamps,\n              currentValues,\n            ]);\n          }\n        }\n      }\n    }\n  }\n\n  return provisions;\n}\n\nfunction processDeclarations(\n  sessionContext: SessionContext,\n  allDeclareTimestamps,\n  allDeclareAttributeTimestamps: Map<Path, AttributeTimestamps>,\n  allDeclareAttributeValues: Map<Path, AttributeValues>,\n): VirtualParameterDeclaration[] {\n  const deviceData = sessionContext.deviceData;\n  const syncState = sessionContext.syncState;\n\n  const paths = deviceData.paths.findCompat(Path.root, false, true, 99);\n  paths.sort((a, b): number =>\n    a.wildcard === b.wildcard ? a.length - b.length : a.wildcard - b.wildcard,\n  );\n\n  const virtualParameterDeclarations = [] as VirtualParameterDeclaration[];\n\n  function func(\n    leafParam: Path,\n    leafIsObject: number,\n    leafTimestamp: number,\n    _paths: Path[],\n  ): void {\n    const currentPath = _paths[0];\n    const children = new Map<string, Path[]>();\n    let declareTimestamp = 0;\n    let declareAttributeTimestamps;\n    let declareAttributeValues;\n\n    let currentTimestamp = 0;\n    let currentAttributes;\n    if (currentPath.wildcard === 0)\n      currentAttributes = deviceData.attributes.get(currentPath);\n\n    for (const path of _paths) {\n      if (path.length > currentPath.length) {\n        const fragment = path.segments[currentPath.length] as string;\n        let child = children.get(fragment);\n        if (!child) {\n          if (path.length > currentPath.length + 1) {\n            // This is to ensure we don't descend more than one step at a time\n            const p = path.slice(0, currentPath.length + 1);\n            child = [p];\n          } else {\n            child = [];\n          }\n          children.set(fragment, child);\n        }\n        child.push(path);\n        continue;\n      }\n\n      currentTimestamp = Math.max(\n        currentTimestamp,\n        deviceData.timestamps.get(path) || 0,\n      );\n      declareTimestamp = Math.max(\n        declareTimestamp,\n        allDeclareTimestamps.get(path) || 0,\n      );\n\n      if (currentPath.wildcard === 0) {\n        const attrs = allDeclareAttributeTimestamps.get(path);\n        if (attrs) {\n          if (declareAttributeTimestamps) {\n            declareAttributeTimestamps = Object.assign(\n              {},\n              declareAttributeTimestamps,\n            );\n            for (const [k, v] of Object.entries(attrs)) {\n              declareAttributeTimestamps[k] = Math.max(\n                v,\n                declareAttributeTimestamps[k] || 0,\n              );\n            }\n          } else {\n            declareAttributeTimestamps = attrs;\n          }\n        }\n\n        declareAttributeValues =\n          allDeclareAttributeValues.get(path) || declareAttributeValues;\n      }\n    }\n\n    if (currentAttributes) {\n      leafParam = currentPath;\n      leafIsObject = currentAttributes.object?.[1];\n      // Possible V8 bug causes null === 0\n      if (leafIsObject != null && leafIsObject === 0)\n        leafTimestamp = Math.max(leafTimestamp, currentAttributes.object[0]);\n    } else {\n      leafTimestamp = Math.max(leafTimestamp, currentTimestamp);\n    }\n\n    switch (\n      currentPath.segments[0] !== \"*\"\n        ? currentPath.segments[0]\n        : leafParam.segments[0]\n    ) {\n      case \"Reboot\":\n        if (currentPath.length === 1) {\n          if (declareAttributeValues?.value)\n            syncState.reboot = +new Date(declareAttributeValues.value[0]);\n        }\n        break;\n      case \"FactoryReset\":\n        if (currentPath.length === 1) {\n          if (declareAttributeValues?.value)\n            syncState.factoryReset = +new Date(declareAttributeValues.value[0]);\n        }\n        break;\n      case \"Tags\":\n        if (\n          currentPath.length === 2 &&\n          currentPath.wildcard === 0 &&\n          declareAttributeValues &&\n          declareAttributeValues.value\n        ) {\n          syncState.tags.set(\n            currentPath,\n            device.sanitizeParameterValue([\n              declareAttributeValues.value[0],\n              \"xsd:boolean\",\n            ])[0] as boolean,\n          );\n        }\n\n        break;\n      case \"Events\":\n      case \"DeviceID\":\n        // Do nothing\n        break;\n      case \"Downloads\":\n        if (\n          currentPath.length === 3 &&\n          currentPath.wildcard === 0 &&\n          declareAttributeValues &&\n          declareAttributeValues.value\n        ) {\n          if (currentPath.segments[2] === \"Download\") {\n            syncState.downloadsDownload.set(\n              currentPath,\n              declareAttributeValues.value[0],\n            );\n          } else {\n            syncState.downloadsValues.set(\n              currentPath,\n              declareAttributeValues.value[0],\n            );\n          }\n        }\n        break;\n      case \"VirtualParameters\":\n        if (currentPath.length <= 2) {\n          let d;\n          if (!(declareTimestamp <= currentTimestamp)) d = [currentPath];\n\n          if (currentPath.wildcard === 0) {\n            if (declareAttributeTimestamps) {\n              for (const [attrName, attrTimestamp] of Object.entries(\n                declareAttributeTimestamps,\n              )) {\n                if (\n                  !(\n                    currentAttributes &&\n                    currentAttributes[attrName] &&\n                    attrTimestamp <= currentAttributes[attrName][0]\n                  )\n                ) {\n                  if (!d) d = [currentPath];\n                  if (!d[1]) d[1] = {};\n                  d[1][attrName] = attrTimestamp;\n                }\n              }\n            }\n\n            if (declareAttributeValues) {\n              if (!d) d = [currentPath];\n              d[2] = declareAttributeValues;\n            }\n          }\n\n          if (d) virtualParameterDeclarations.push(d);\n        }\n        break;\n      default:\n        if (\n          declareTimestamp > currentTimestamp &&\n          declareTimestamp > leafTimestamp\n        ) {\n          if (currentPath === leafParam) {\n            syncState.refreshAttributes.exist.add(leafParam);\n          } else if (leafIsObject) {\n            syncState.gpn.add(leafParam);\n            if (leafTimestamp > 0) {\n              const f = 1 << leafParam.length;\n              syncState.gpnPatterns.set(\n                leafParam,\n                f | syncState.gpnPatterns.get(leafParam),\n              );\n            } else {\n              const f =\n                ((1 << currentPath.length) - 1) ^ ((1 << leafParam.length) - 1);\n              syncState.gpnPatterns.set(\n                currentPath,\n                f | syncState.gpnPatterns.get(currentPath),\n              );\n            }\n          } else {\n            syncState.refreshAttributes.object.add(leafParam);\n            if (leafIsObject == null) {\n              const f =\n                ((1 << syncState.gpnPatterns.size) - 1) ^\n                ((1 << leafParam.length) - 1);\n              syncState.gpnPatterns.set(\n                currentPath,\n                f | syncState.gpnPatterns.get(currentPath),\n              );\n            }\n          }\n        }\n\n        if (currentAttributes) {\n          if (declareAttributeTimestamps) {\n            for (const [attrName, attrTimestamp] of Object.entries(\n              declareAttributeTimestamps,\n            )) {\n              if (\n                !(\n                  currentAttributes[attrName] &&\n                  attrTimestamp <= currentAttributes[attrName][0]\n                )\n              ) {\n                if (attrName === \"value\") {\n                  if (\n                    !(\n                      currentAttributes.object &&\n                      currentAttributes.object[1] != null\n                    )\n                  )\n                    syncState.refreshAttributes.object.add(currentPath);\n                  else if (currentAttributes.object[1] === 0)\n                    syncState.refreshAttributes.value.add(currentPath);\n                } else if (attrName in syncState.refreshAttributes) {\n                  syncState.refreshAttributes[attrName].add(currentPath);\n                }\n              }\n            }\n          }\n          if (declareAttributeValues) {\n            if (declareAttributeValues.value != null)\n              syncState.spv.set(currentPath, declareAttributeValues.value);\n\n            if (declareAttributeValues.notification != null) {\n              const spa = syncState.spa.get(currentPath);\n              if (spa) {\n                spa.notification = declareAttributeValues.notification;\n              } else {\n                syncState.spa.set(currentPath, {\n                  notification: declareAttributeValues.notification,\n                  accessList: null,\n                });\n              }\n            }\n\n            if (declareAttributeValues.accessList != null) {\n              const spa = syncState.spa.get(currentPath);\n              if (spa) {\n                spa.accessList = declareAttributeValues.accessList;\n              } else {\n                syncState.spa.set(currentPath, {\n                  notification: null,\n                  accessList: declareAttributeValues.accessList,\n                });\n              }\n            }\n          }\n        }\n    }\n\n    for (let [fragment, child] of children) {\n      // This fine expression avoids duplicate visits, don't ask.\n      if (\n        ((currentPath.wildcard ^ child[0].wildcard) &\n          ((1 << currentPath.length) - 1)) >>\n          leafParam.length ===\n        0\n      ) {\n        if (fragment !== \"*\") {\n          const wildcardChild = children.get(\"*\");\n          if (wildcardChild) child = child.concat(wildcardChild);\n        }\n        func(leafParam, leafIsObject, leafTimestamp, child);\n      }\n    }\n  }\n\n  if (\n    allDeclareTimestamps.size ||\n    allDeclareAttributeTimestamps.size ||\n    allDeclareAttributeValues.size\n  )\n    func(Path.root, 1, 0, [Path.root, ...paths]);\n\n  return virtualParameterDeclarations;\n}\n\nfunction processInstances(\n  sessionContext: SessionContext,\n  parent: Path,\n  parameters: Path[],\n  keys: Record<string, string>,\n  minInstances: number,\n  maxInstances: number,\n  defer: boolean,\n): void {\n  parent = sessionContext.deviceData.paths.add(parent.toString());\n  let instancesToCreate: InstanceSet, instancesToDelete: Set<Path>;\n  if (parent.segments[0] === \"Downloads\") {\n    if (parent.length !== 1) return;\n    instancesToDelete = sessionContext.syncState.downloadsToDelete;\n    instancesToCreate = sessionContext.syncState.downloadsToCreate;\n  } else {\n    instancesToDelete = sessionContext.syncState.instancesToDelete.get(parent);\n    if (instancesToDelete == null) {\n      instancesToDelete = new Set();\n      sessionContext.syncState.instancesToDelete.set(parent, instancesToDelete);\n    }\n\n    instancesToCreate = sessionContext.syncState.instancesToCreate.get(parent);\n    if (instancesToCreate == null) {\n      instancesToCreate = new InstanceSet();\n      sessionContext.syncState.instancesToCreate.set(parent, instancesToCreate);\n    }\n  }\n\n  if (defer && instancesToCreate.size === 0 && instancesToDelete.size === 0)\n    return;\n\n  let counter = 0;\n  for (const p of parameters) {\n    ++counter;\n    if (counter > maxInstances) instancesToDelete.add(p);\n    else if (counter <= minInstances) instancesToDelete.delete(p);\n  }\n\n  // Key is null if deleting a particular instance rather than use alias\n  if (!keys) return;\n\n  for (const inst of instancesToCreate.superset(keys)) {\n    ++counter;\n    if (counter > maxInstances) instancesToCreate.delete(inst);\n  }\n\n  for (const inst of instancesToCreate.subset(keys)) {\n    ++counter;\n    if (counter <= minInstances) {\n      instancesToCreate.delete(inst);\n      instancesToCreate.add(JSON.parse(JSON.stringify(keys)));\n    }\n  }\n\n  while (counter < minInstances) {\n    ++counter;\n    instancesToCreate.add(JSON.parse(JSON.stringify(keys)));\n  }\n}\n\nexport async function rpcResponse(\n  sessionContext: SessionContext,\n  id: string,\n  _rpcRes: CpeResponse,\n): Promise<Fault> {\n  function invalidResponse(message: string): Fault {\n    return {\n      code: \"invalid_response\",\n      message: message,\n    };\n  }\n\n  if (id !== generateRpcId(sessionContext))\n    return invalidResponse(\"Request ID not recognized\");\n\n  ++sessionContext.rpcCount;\n\n  const rpcRes = _rpcRes;\n  const rpcReq: typeof sessionContext.rpcRequest & { next?: string } =\n    sessionContext.rpcRequest;\n\n  if (!rpcReq.next) {\n    sessionContext.rpcRequest = null;\n  } else if (rpcReq.next === \"getInstanceKeys\") {\n    const parameterNames = [];\n    const instanceValues: Record<string, string> = {};\n    const req = rpcReq as AddObject;\n    const res = rpcRes as AddObjectResponse;\n    for (const [k, v] of Object.entries(req.instanceValues)) {\n      const n = `${req.objectName}${res.instanceNumber}.${k}`;\n      parameterNames.push(n);\n      instanceValues[n] = v;\n    }\n\n    if (!parameterNames.length) {\n      sessionContext.rpcRequest = null;\n    } else {\n      const r: GetParameterValues & {\n        next: \"setInstanceKeys\";\n        instanceValues: Record<string, string>;\n      } = {\n        name: \"GetParameterValues\",\n        parameterNames: parameterNames,\n        next: \"setInstanceKeys\",\n        instanceValues: instanceValues,\n      };\n      sessionContext.rpcRequest = r;\n    }\n  } else if (rpcReq.next === \"setInstanceKeys\") {\n    const req = rpcReq as GetParameterValues & {\n      instanceValues: Record<string, string>;\n    };\n    const res = rpcRes as GetParameterValuesResponse;\n    const parameterList: [string, string | number | boolean, string][] = [];\n    for (const p of res.parameterList) {\n      if (p[1] !== req.instanceValues[p[0].toString()]) {\n        const v = device.sanitizeParameterValue([\n          req.instanceValues[p[0].toString()],\n          p[2] as string,\n        ]);\n        parameterList.push([p[0].toString(), v[0], v[1]]);\n      }\n    }\n\n    if (!parameterList.length) {\n      sessionContext.rpcRequest = null;\n    } else {\n      const DATETIME_MILLISECONDS = localCache.getConfig(\n        sessionContext.cacheSnapshot,\n        \"cwmp.datetimeMilliseconds\",\n        true,\n        (e) => configContextCallback(sessionContext, e),\n      );\n\n      const BOOLEAN_LITERAL = localCache.getConfig(\n        sessionContext.cacheSnapshot,\n        \"cwmp.booleanLiteral\",\n        true,\n        (e) => configContextCallback(sessionContext, e),\n      );\n\n      const r: SetParameterValues = {\n        name: \"SetParameterValues\",\n        parameterList: parameterList,\n        DATETIME_MILLISECONDS: DATETIME_MILLISECONDS,\n        BOOLEAN_LITERAL: BOOLEAN_LITERAL,\n      };\n      sessionContext.rpcRequest = r;\n    }\n  }\n\n  const timestamp = sessionContext.timestamp + sessionContext.iteration;\n\n  const revision =\n    (sessionContext.revisions[sessionContext.revisions.length - 1] || 0) + 1;\n  sessionContext.deviceData.timestamps.revision = revision;\n  sessionContext.deviceData.attributes.revision = revision;\n\n  let toClear: Clear[];\n\n  if (rpcRes.name === \"GetParameterValuesResponse\") {\n    if (rpcReq.name !== \"GetParameterValues\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    const requested = new Set(rpcReq.parameterNames);\n\n    for (const [path, value, type] of rpcRes.parameterList) {\n      if (!requested.delete(path.toString())) {\n        logger.accessWarn({\n          sessionContext: sessionContext,\n          message: \"Unexpected parameter in response\",\n          parameter: path.toString(),\n        });\n        continue;\n      }\n      toClear = device.set(\n        sessionContext.deviceData,\n        path.toString(),\n        timestamp,\n        {\n          object: [timestamp, 0],\n          value: [timestamp, [value, type]],\n        },\n        toClear,\n      );\n    }\n\n    if (requested.size) {\n      for (const p of requested) {\n        logger.accessWarn({\n          sessionContext: sessionContext,\n          message: \"Missing parameter in response\",\n          parameter: p,\n        });\n        toClear = device.set(\n          sessionContext.deviceData,\n          p,\n          timestamp,\n          {\n            object: [timestamp, 0],\n            value: [timestamp, [\"\", \"xsd:string\"]],\n          },\n          toClear,\n        );\n      }\n    }\n  } else if (rpcRes.name === \"GetParameterAttributesResponse\") {\n    if (rpcReq.name !== \"GetParameterAttributes\")\n      throw new Error(\"Response name does not match request name\");\n\n    const requested = new Set(rpcReq.parameterNames);\n\n    for (const [path, notification, accessList] of rpcRes.parameterList) {\n      if (!requested.delete(path.toString())) {\n        logger.accessWarn({\n          sessionContext: sessionContext,\n          message: \"Unexpected parameter in response\",\n          parameter: path.toString(),\n        });\n        continue;\n      }\n      toClear = device.set(\n        sessionContext.deviceData,\n        path.toString(),\n        timestamp,\n        {\n          notification: [timestamp, notification],\n          accessList: [timestamp, accessList],\n        },\n        toClear,\n      );\n    }\n\n    if (requested.size) {\n      for (const p of requested) {\n        logger.accessWarn({\n          sessionContext: sessionContext,\n          message: \"Missing parameter in response\",\n          parameter: p,\n        });\n        toClear = device.set(\n          sessionContext.deviceData,\n          p,\n          timestamp,\n          {\n            notification: [timestamp, 0],\n            accessList: [timestamp, []],\n          },\n          toClear,\n        );\n      }\n    }\n  } else if (rpcRes.name === \"GetParameterNamesResponse\") {\n    if (rpcReq.name !== \"GetParameterNames\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    let root: Path;\n    if (!rpcReq.parameterPath) root = Path.root;\n    else if (rpcReq.parameterPath.endsWith(\".\"))\n      root = Path.parse(rpcReq.parameterPath.slice(0, -1));\n    else root = Path.parse(rpcReq.parameterPath);\n\n    // Sort to help fill in missing params\n    rpcRes.parameterList.sort((a, b) => {\n      const pa = a[0];\n      const pb = b[0];\n      const l = Math.min(pa.length, pb.length);\n      for (let i = 0; i < l; ++i) {\n        if (pa.segments[i] > pb.segments[i]) return 1;\n        if (pa.segments[i] < pb.segments[i]) return -1;\n      }\n      return pa.length - pb.length;\n    });\n\n    // Fill in missing unreported parent params\n    for (let idx = 1; idx < rpcRes.parameterList.length; ++idx) {\n      const prev = rpcRes.parameterList[idx - 1][0];\n      const cur = rpcRes.parameterList[idx][0];\n      let offset = 0;\n      for (let i = cur.length - 2; i >= 0; --i) {\n        if (i < prev.length && prev.segments[i] === cur.segments[i]) {\n          // Set object to true in case CPE didn't indicate that it's an object\n          if (i === prev.length - 1) rpcRes.parameterList[idx - 1][1] = true;\n          break;\n        }\n        // TODO consider showing a warning\n        rpcRes.parameterList.splice(idx, 0, [cur.slice(0, i + 1), true, true]);\n        ++offset;\n      }\n      idx += offset;\n    }\n\n    if (!root.length) {\n      for (const n of [\n        \"DeviceID\",\n        \"Events\",\n        \"Tags\",\n        \"Reboot\",\n        \"FactoryReset\",\n        \"VirtualParameters\",\n        \"Downloads\",\n      ]) {\n        const p = sessionContext.deviceData.paths.get(n);\n        if (p && sessionContext.deviceData.attributes.has(p))\n          sessionContext.deviceData.timestamps.set(p, timestamp);\n      }\n    }\n\n    const wildcardPath = Path.parse(\"*\");\n    const wildcardParams: Path[] = [root.concat(wildcardPath)];\n\n    for (const [path, object, writable] of rpcRes.parameterList) {\n      if (\n        !path.toString().startsWith(rpcReq.parameterPath) &&\n        !(`${path.toString()}.` === rpcReq.parameterPath && !rpcReq.nextLevel)\n      ) {\n        logger.accessWarn({\n          sessionContext: sessionContext,\n          message: \"Unexpected parameter in response\",\n          parameter: path.toString(),\n        });\n        continue;\n      }\n      if (object && !rpcReq.nextLevel)\n        wildcardParams.push(path.concat(wildcardPath));\n\n      toClear = device.set(\n        sessionContext.deviceData,\n        path.toString(),\n        timestamp,\n        {\n          object: [timestamp, object ? 1 : 0],\n          writable: [timestamp, writable ? 1 : 0],\n        },\n        toClear,\n      );\n    }\n\n    for (const path of wildcardParams) {\n      toClear = device.set(\n        sessionContext.deviceData,\n        path.toString(),\n        timestamp,\n        null,\n        toClear,\n      );\n    }\n  } else if (rpcRes.name === \"SetParameterValuesResponse\") {\n    if (rpcReq.name !== \"SetParameterValues\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    for (const p of rpcReq.parameterList) {\n      toClear = device.set(\n        sessionContext.deviceData,\n        p[0],\n        timestamp + 1,\n        {\n          object: [timestamp + 1, 0],\n          value: [\n            timestamp + 1,\n            p.slice(1) as [string | number | boolean, string],\n          ],\n        },\n        toClear,\n      );\n    }\n  } else if (rpcRes.name === \"SetParameterAttributesResponse\") {\n    if (rpcReq.name !== \"SetParameterAttributes\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    for (const p of rpcReq.parameterList) {\n      let attrs;\n\n      if (p[1] != null && p[2] != null) {\n        attrs = {\n          notification: [timestamp + 1, p[1]],\n          accessList: [timestamp + 1, p[2]],\n        };\n      } else if (p[1] != null) {\n        attrs = {\n          notification: [timestamp + 1, p[1]],\n        };\n      } else if (p[2] != null) {\n        attrs = {\n          accessList: [timestamp + 1, p[2]],\n        };\n      }\n\n      toClear = device.set(\n        sessionContext.deviceData,\n        p[0],\n        timestamp + 1,\n        attrs,\n        toClear,\n      );\n    }\n  } else if (rpcRes.name === \"AddObjectResponse\") {\n    if (rpcReq.name !== \"AddObject\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    toClear = device.set(\n      sessionContext.deviceData,\n      rpcReq.objectName + rpcRes.instanceNumber,\n      timestamp + 1,\n      { object: [timestamp + 1, 1] },\n      toClear,\n    );\n  } else if (rpcRes.name === \"DeleteObjectResponse\") {\n    if (rpcReq.name !== \"DeleteObject\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    toClear = device.set(\n      sessionContext.deviceData,\n      rpcReq.objectName.slice(0, -1),\n      timestamp + 1,\n      null,\n      toClear,\n    );\n  } else if (rpcRes.name === \"RebootResponse\") {\n    if (rpcReq.name !== \"Reboot\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    toClear = device.set(\n      sessionContext.deviceData,\n      \"Reboot\",\n      timestamp + 1,\n      { value: [timestamp + 1, [sessionContext.timestamp, \"xsd:dateTime\"]] },\n      toClear,\n    );\n  } else if (rpcRes.name === \"FactoryResetResponse\") {\n    if (rpcReq.name !== \"FactoryReset\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    toClear = device.set(\n      sessionContext.deviceData,\n      \"FactoryReset\",\n      timestamp + 1,\n      { value: [timestamp + 1, [sessionContext.timestamp, \"xsd:dateTime\"]] },\n      toClear,\n    );\n  } else if (rpcRes.name === \"DownloadResponse\") {\n    if (rpcReq.name !== \"Download\")\n      return invalidResponse(\"Response name does not match request name\");\n\n    toClear = device.set(\n      sessionContext.deviceData,\n      `Downloads.${rpcReq.instance}.Download`,\n      timestamp + 1,\n      { value: [timestamp + 1, [sessionContext.timestamp, \"xsd:dateTime\"]] },\n      toClear,\n    );\n\n    if (rpcRes.status === 0) {\n      toClear = device.set(\n        sessionContext.deviceData,\n        `Downloads.${rpcReq.instance}.LastDownload`,\n        timestamp + 1,\n        {\n          value: [timestamp + 1, [sessionContext.timestamp, \"xsd:dateTime\"]],\n        },\n        toClear,\n      );\n\n      toClear = device.set(\n        sessionContext.deviceData,\n        `Downloads.${rpcReq.instance}.LastFileType`,\n        timestamp + 1,\n        { value: [timestamp + 1, [rpcReq.fileType, \"xsd:string\"]] },\n        toClear,\n      );\n\n      toClear = device.set(\n        sessionContext.deviceData,\n        `Downloads.${rpcReq.instance}.LastFileName`,\n        timestamp + 1,\n        { value: [timestamp + 1, [rpcReq.fileType, \"xsd:string\"]] },\n        toClear,\n      );\n\n      toClear = device.set(\n        sessionContext.deviceData,\n        `Downloads.${rpcReq.instance}.LastTargetFileName`,\n        timestamp + 1,\n        { value: [timestamp + 1, [rpcReq.fileType, \"xsd:string\"]] },\n        toClear,\n      );\n\n      toClear = device.set(\n        sessionContext.deviceData,\n        `Downloads.${rpcReq.instance}.StartTime`,\n        timestamp + 1,\n        { value: [timestamp + 1, [+rpcRes.startTime, \"xsd:dateTime\"]] },\n        toClear,\n      );\n\n      toClear = device.set(\n        sessionContext.deviceData,\n        `Downloads.${rpcReq.instance}.CompleteTime`,\n        timestamp + 1,\n        { value: [timestamp + 1, [+rpcRes.completeTime, \"xsd:dateTime\"]] },\n        toClear,\n      );\n    } else {\n      const operation = {\n        name: \"Download\",\n        timestamp: sessionContext.timestamp,\n        provisions: sessionContext.provisions,\n        channels: sessionContext.channels,\n        retries: {},\n        args: {\n          instance: rpcReq.instance,\n          fileType: rpcReq.fileType,\n          fileName: rpcReq.fileName,\n          targetFileName: rpcReq.targetFileName,\n        },\n      };\n\n      for (const channel of Object.keys(sessionContext.channels)) {\n        if (sessionContext.retries[channel] != null)\n          operation.retries[channel] = sessionContext.retries[channel];\n      }\n\n      sessionContext.operations[rpcReq.commandKey] = operation;\n      if (!sessionContext.operationsTouched)\n        sessionContext.operationsTouched = {};\n      sessionContext.operationsTouched[rpcReq.commandKey] = 1;\n    }\n  } else {\n    return invalidResponse(\"Response name not recognized\");\n  }\n\n  if (toClear) {\n    for (const c of toClear)\n      device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n  }\n\n  return null;\n}\n\nexport async function rpcFault(\n  sessionContext: SessionContext,\n  id: string,\n  faultResponse: CpeFault,\n): Promise<Fault> {\n  const rpcReq = sessionContext.rpcRequest;\n  delete sessionContext.syncState;\n  delete sessionContext.rpcRequest;\n  ++sessionContext.rpcCount;\n\n  // Recover from invalid parameter name faults\n  if (faultResponse.detail.faultCode === \"9005\") {\n    const timestamp = sessionContext.timestamp + sessionContext.iteration + 1;\n    const revision =\n      (sessionContext.revisions[sessionContext.revisions.length - 1] || 0) + 1;\n    sessionContext.deviceData.timestamps.revision = revision;\n    sessionContext.deviceData.attributes.revision = revision;\n\n    let toClear: Clear[];\n    if (rpcReq.name === \"GetParameterNames\") {\n      if (rpcReq.parameterPath) {\n        toClear = [\n          [Path.parse(rpcReq.parameterPath.replace(/\\.$/, \"\")), timestamp],\n        ];\n      }\n    } else if (rpcReq.name === \"GetParameterValues\") {\n      toClear = rpcReq.parameterNames.map(\n        (p) => [Path.parse(p.replace(/\\.$/, \"\")), timestamp] as Clear,\n      );\n    } else if (rpcReq.name === \"SetParameterValues\") {\n      toClear = (\n        rpcReq.parameterList as [string, string | number | boolean, string][]\n      ).map((p) => [Path.parse(p[0].replace(/\\.$/, \"\")), timestamp] as Clear);\n    } else if (rpcReq.name === \"AddObject\") {\n      toClear = [[Path.parse(rpcReq.objectName.replace(/\\.$/, \"\")), timestamp]];\n    } else if (rpcReq.name === \"DeleteObject\") {\n      toClear = [[Path.parse(rpcReq.objectName.replace(/\\.$/, \"\")), timestamp]];\n    } else if (rpcReq.name === \"GetParameterAttributes\") {\n      toClear = rpcReq.parameterNames.map(\n        (p) => [Path.parse(p.replace(/\\.$/, \"\")), timestamp] as Clear,\n      );\n    } else if (rpcReq.name === \"SetParameterAttributes\") {\n      toClear = (rpcReq.parameterList as [string, number, string[]][]).map(\n        (p) => [Path.parse(p[0].replace(/\\.$/, \"\")), timestamp] as Clear,\n      );\n    }\n\n    if (toClear) {\n      for (const c of toClear)\n        device.clear(sessionContext.deviceData, c[0], c[1], c[2], c[3]);\n      return null;\n    }\n  }\n\n  const fault: Fault = {\n    code: `cwmp.${faultResponse.detail.faultCode}`,\n    message: faultResponse.detail.faultString,\n    detail: faultResponse.detail,\n  };\n\n  return fault;\n}\n\nexport async function deserialize(\n  sessionContextString: string,\n): Promise<SessionContext> {\n  const sessionContext = JSON.parse(sessionContextString) as SessionContext;\n\n  for (const decs of sessionContext.declarations)\n    for (const d of decs) d.path = Path.parse(d.path as unknown as string);\n\n  const deviceData = initDeviceData();\n  for (const r of sessionContext.deviceData as unknown as any[]) {\n    const path = deviceData.paths.add(r[0]);\n\n    if (r[1]) deviceData.trackers.set(path, r[1]);\n\n    if (r[2]) {\n      deviceData.timestamps.setRevisions(path, r[2]);\n      if (r[3]) deviceData.attributes.setRevisions(path, r[3]);\n    }\n  }\n\n  sessionContext.deviceData = deviceData;\n  // Ensure cache is populated\n  await localCache.getRevision();\n\n  return sessionContext;\n}\n\nexport async function serialize(\n  sessionContext: SessionContext,\n): Promise<string> {\n  const deviceData = [];\n\n  for (const path of sessionContext.deviceData.paths.findCompat(\n    Path.root,\n    false,\n    false,\n    99,\n  )) {\n    const e = [\n      path.toString(),\n      sessionContext.deviceData.trackers.get(path) || null,\n      sessionContext.deviceData.timestamps.getRevisions(path) || null,\n      sessionContext.deviceData.attributes.getRevisions(path) || null,\n    ];\n    deviceData.push(e);\n  }\n\n  const declarations = sessionContext.declarations.map((decs) => {\n    return decs.map((d) => Object.assign({}, d, { path: d.path.toString() }));\n  });\n\n  const jsonSessionContext = Object.assign({}, sessionContext, {\n    deviceData: deviceData,\n    declarations: declarations,\n    syncState: null,\n    toLoad: null,\n    httpRequest: null,\n    httpResponse: null,\n  });\n\n  const sessionContextString = JSON.stringify(jsonSessionContext);\n\n  return sessionContextString;\n}\n"
  },
  {
    "path": "lib/soap.ts",
    "content": "import {\n  parseXml,\n  Element,\n  parseAttrs,\n  encodeEntities,\n  decodeEntities,\n} from \"./xml-parser.ts\";\nimport memoize from \"./common/memoize.ts\";\nimport { version as VERSION } from \"../package.json\";\nimport {\n  InformRequest,\n  FaultStruct,\n  SpvFault,\n  CpeFault,\n  SoapMessage,\n  TransferCompleteRequest,\n  AcsRequest,\n  type GetParameterNamesResponse,\n  type GetParameterValuesResponse,\n  type GetParameterAttributesResponse,\n  type SetParameterValuesResponse,\n  type SetParameterAttributesResponse,\n  type AddObjectResponse,\n  type DeleteObject,\n  type DeleteObjectResponse,\n  type RebootResponse,\n  type FactoryResetResponse,\n  type DownloadResponse,\n  GetRPCMethodsRequest,\n  RequestDownloadRequest,\n  AcsResponse,\n} from \"./types.ts\";\nimport Path from \"./common/path.ts\";\n\nconst SERVER_NAME = `GenieACS/${VERSION}`;\n\nconst NAMESPACES = {\n  \"1.0\": {\n    \"soap-enc\": \"http://schemas.xmlsoap.org/soap/encoding/\",\n    \"soap-env\": \"http://schemas.xmlsoap.org/soap/envelope/\",\n    xsd: \"http://www.w3.org/2001/XMLSchema\",\n    xsi: \"http://www.w3.org/2001/XMLSchema-instance\",\n    cwmp: \"urn:dslforum-org:cwmp-1-0\",\n  },\n  \"1.1\": {\n    \"soap-enc\": \"http://schemas.xmlsoap.org/soap/encoding/\",\n    \"soap-env\": \"http://schemas.xmlsoap.org/soap/envelope/\",\n    xsd: \"http://www.w3.org/2001/XMLSchema\",\n    xsi: \"http://www.w3.org/2001/XMLSchema-instance\",\n    cwmp: \"urn:dslforum-org:cwmp-1-1\",\n  },\n  \"1.2\": {\n    \"soap-enc\": \"http://schemas.xmlsoap.org/soap/encoding/\",\n    \"soap-env\": \"http://schemas.xmlsoap.org/soap/envelope/\",\n    xsd: \"http://www.w3.org/2001/XMLSchema\",\n    xsi: \"http://www.w3.org/2001/XMLSchema-instance\",\n    cwmp: \"urn:dslforum-org:cwmp-1-2\",\n  },\n  \"1.3\": {\n    \"soap-enc\": \"http://schemas.xmlsoap.org/soap/encoding/\",\n    \"soap-env\": \"http://schemas.xmlsoap.org/soap/envelope/\",\n    xsd: \"http://www.w3.org/2001/XMLSchema\",\n    xsi: \"http://www.w3.org/2001/XMLSchema-instance\",\n    cwmp: \"urn:dslforum-org:cwmp-1-2\",\n  },\n  \"1.4\": {\n    \"soap-enc\": \"http://schemas.xmlsoap.org/soap/encoding/\",\n    \"soap-env\": \"http://schemas.xmlsoap.org/soap/envelope/\",\n    xsd: \"http://www.w3.org/2001/XMLSchema\",\n    xsi: \"http://www.w3.org/2001/XMLSchema-instance\",\n    cwmp: \"urn:dslforum-org:cwmp-1-3\",\n  },\n};\n\nlet warnings: Record<string, unknown>[];\n\nconst memoizedParseAttrs = memoize(parseAttrs);\n\nfunction parseBool(v: string): boolean {\n  if (v === \"true\" || v === \"1\") return true;\n  if (v === \"false\" || v === \"0\") return false;\n  return null;\n}\n\nfunction event(xml: Element): string[] {\n  return xml.children\n    .filter((n) => n.localName === \"EventStruct\")\n    .map((c) => c.children.find((n) => n.localName === \"EventCode\").text);\n}\n\nfunction parameterInfoList(xml: Element): [Path, boolean, boolean][] {\n  return xml.children\n    .map<[Path, boolean, boolean]>((e) => {\n      if (e.localName !== \"ParameterInfoStruct\") return null;\n      let param: string, value: string;\n      for (const c of e.children) {\n        switch (c.localName) {\n          case \"Name\":\n            param = c.text;\n            break;\n          case \"Writable\":\n            value = c.text;\n            break;\n        }\n      }\n\n      let parsed: boolean = parseBool(value);\n\n      if (parsed == null) {\n        warnings.push({\n          message: \"Missing or invalid XML node\",\n          element: \"Writable\",\n          parameter: param,\n        });\n        parsed = false;\n      }\n\n      try {\n        if (param && !param.endsWith(\".\"))\n          return [Path.parse(param), false, parsed];\n        else return [Path.parse(param.slice(0, -1)), true, parsed];\n      } catch {\n        warnings.push({\n          message: \"Missing or invalid XML node\",\n          element: \"Name\",\n          parameter: param,\n        });\n        return null;\n      }\n    })\n    .filter((e) => e != null);\n}\n\nconst getValueType = memoize((str: string) => {\n  const attrs = parseAttrs(str);\n  for (const attr of attrs) if (attr.localName === \"type\") return attr.value;\n\n  return null;\n});\n\nfunction parameterValueList(\n  xml: Element,\n): [Path, string | number | boolean, string][] {\n  return xml.children\n    .map<[Path, string | number | boolean, string]>((e) => {\n      if (e.localName !== \"ParameterValueStruct\") return null;\n      let valueElement: Element, param: string;\n      for (const c of e.children) {\n        switch (c.localName) {\n          case \"Name\":\n            param = c.text;\n            break;\n          case \"Value\":\n            valueElement = c;\n            break;\n        }\n      }\n\n      let valueType = getValueType(valueElement.attrs);\n      if (!valueType) {\n        warnings.push({\n          message: \"Missing or invalid XML node\",\n          attribute: \"type\",\n          parameter: param,\n        });\n        valueType = \"xsd:string\";\n      }\n\n      const value = decodeEntities(valueElement.text);\n      let parsed: string | number | boolean = value;\n      if (valueType === \"xsd:boolean\") {\n        parsed = parseBool(value);\n        if (parsed == null) {\n          warnings.push({\n            message: \"Missing or invalid XML node\",\n            element: \"Value\",\n            parameter: param,\n          });\n          parsed = value;\n        }\n      } else if (valueType === \"xsd:int\" || valueType === \"xsd:unsignedInt\") {\n        parsed = parseInt(value);\n        if (isNaN(parsed)) {\n          warnings.push({\n            message: \"Missing or invalid XML node\",\n            element: \"Value\",\n            parameter: param,\n          });\n          parsed = value;\n        }\n      } else if (valueType === \"xsd:dateTime\") {\n        parsed = Date.parse(value);\n        if (isNaN(parsed)) {\n          warnings.push({\n            message: \"Missing or invalid XML node\",\n            element: \"Value\",\n            parameter: param,\n          });\n          parsed = value;\n        }\n      }\n      try {\n        return [Path.parse(param), parsed, valueType];\n      } catch {\n        warnings.push({\n          message: \"Missing or invalid XML node\",\n          element: \"Name\",\n          parameter: param,\n        });\n        return null;\n      }\n    })\n    .filter((e) => e != null);\n}\n\nfunction parameterAttributeList(xml: Element): [Path, number, string[]][] {\n  return xml.children\n    .map<[Path, number, string[]]>((e) => {\n      if (e.localName !== \"ParameterAttributeStruct\") return null;\n      let notificationElement: Element,\n        accessListElement: Element,\n        param: string;\n      for (const c of e.children) {\n        switch (c.localName) {\n          case \"Name\":\n            param = c.text;\n            break;\n          case \"Notification\":\n            notificationElement = c;\n            break;\n          case \"AccessList\":\n            accessListElement = c;\n            break;\n        }\n      }\n\n      let notification = parseInt(notificationElement.text);\n      if (isNaN(notification)) {\n        warnings.push({\n          message: \"Missing or invalid XML node\",\n          element: \"Notification\",\n          parameter: param,\n        });\n        notification = 0;\n      }\n\n      const accessList = accessListElement.children\n        .filter((c) => c.localName === \"string\")\n        .map((c) => decodeEntities(c.text));\n\n      try {\n        return [Path.parse(param), notification, accessList];\n      } catch {\n        warnings.push({\n          message: \"Missing or invalid XML node\",\n          element: \"Name\",\n          parameter: param,\n        });\n        return null;\n      }\n    })\n    .filter((e) => e != null);\n}\n\nfunction GetParameterNames(methodRequest): string {\n  return `<cwmp:GetParameterNames><ParameterPath>${\n    methodRequest.parameterPath\n  }</ParameterPath><NextLevel>${+methodRequest.nextLevel}</NextLevel></cwmp:GetParameterNames>`;\n}\n\nfunction GetParameterNamesResponse(xml): GetParameterNamesResponse {\n  return {\n    name: \"GetParameterNamesResponse\",\n    parameterList: parameterInfoList(\n      xml.children.find((n) => n.localName === \"ParameterList\"),\n    ),\n  };\n}\n\nfunction GetParameterValues(methodRequest): string {\n  return `<cwmp:GetParameterValues><ParameterNames soap-enc:arrayType=\"xsd:string[${\n    methodRequest.parameterNames.length\n  }]\">${methodRequest.parameterNames\n    .map((p) => `<string>${p}</string>`)\n    .join(\"\")}</ParameterNames></cwmp:GetParameterValues>`;\n}\n\nfunction GetParameterValuesResponse(xml: Element): GetParameterValuesResponse {\n  return {\n    name: \"GetParameterValuesResponse\",\n    parameterList: parameterValueList(\n      xml.children.find((n) => n.localName === \"ParameterList\"),\n    ),\n  };\n}\n\nfunction GetParameterAttributes(methodRequest): string {\n  return `<cwmp:GetParameterAttributes><ParameterNames soap-enc:arrayType=\"xsd:string[${\n    methodRequest.parameterNames.length\n  }]\">${methodRequest.parameterNames\n    .map((p) => `<string>${p}</string>`)\n    .join(\"\")}</ParameterNames></cwmp:GetParameterAttributes>`;\n}\n\nfunction GetParameterAttributesResponse(\n  xml: Element,\n): GetParameterAttributesResponse {\n  return {\n    name: \"GetParameterAttributesResponse\",\n    parameterList: parameterAttributeList(\n      xml.children.find((n) => n.localName === \"ParameterList\"),\n    ),\n  };\n}\n\nfunction SetParameterValues(methodRequest): string {\n  const params = methodRequest.parameterList.map((p) => {\n    let val = p[1];\n    if (p[2] === \"xsd:dateTime\" && typeof val === \"number\") {\n      val = new Date(val).toISOString();\n      if (methodRequest.DATETIME_MILLISECONDS === false)\n        val = val.replace(\".000\", \"\");\n    }\n    if (p[2] === \"xsd:boolean\" && typeof val === \"boolean\")\n      if (methodRequest.BOOLEAN_LITERAL === false) val = +val;\n    return `<ParameterValueStruct><Name>${p[0]}</Name><Value xsi:type=\"${\n      p[2]\n    }\">${encodeEntities(\"\" + val)}</Value></ParameterValueStruct>`;\n  });\n\n  return `<cwmp:SetParameterValues><ParameterList soap-enc:arrayType=\"cwmp:ParameterValueStruct[${\n    methodRequest.parameterList.length\n  }]\">${params.join(\"\")}</ParameterList><ParameterKey>${\n    methodRequest.parameterKey || \"\"\n  }</ParameterKey></cwmp:SetParameterValues>`;\n}\n\nfunction SetParameterValuesResponse(xml: Element): SetParameterValuesResponse {\n  let status: number;\n\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"Status\":\n        status = parseInt(c.text);\n        break;\n    }\n  }\n\n  if (!(status >= 0)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"Status\",\n    });\n    status = 0;\n  }\n\n  return {\n    name: \"SetParameterValuesResponse\",\n    status: status,\n  };\n}\n\nfunction SetParameterAttributes(methodRequest): string {\n  const params = methodRequest.parameterList.map((p) => {\n    return `<SetParameterAttributesStruct><Name>${\n      p[0]\n    }</Name><NotificationChange>${\n      p[1] == null ? \"false\" : \"true\"\n    }</NotificationChange><Notification>${\n      p[1] == null ? \"\" : p[1]\n    }</Notification><AccessListChange>${\n      p[2] == null ? \"false\" : \"true\"\n    }</AccessListChange><AccessList soap-enc:arrayType=\"xsd:string[${\n      (p[2] || []).length\n    }]\">${\n      p[2] == null\n        ? \"\"\n        : p[2].map((s) => `<string>${encodeEntities(s)}</string>`).join(\"\")\n    }</AccessList></SetParameterAttributesStruct>`;\n  });\n\n  return `<cwmp:SetParameterAttributes><ParameterList soap-enc:arrayType=\"cwmp:SetParameterAttributesStruct[${\n    methodRequest.parameterList.length\n  }]\">${params.join(\"\")}</ParameterList></cwmp:SetParameterAttributes>`;\n}\n\nfunction SetParameterAttributesResponse(): SetParameterAttributesResponse {\n  return {\n    name: \"SetParameterAttributesResponse\",\n  };\n}\n\nfunction AddObject(methodRequest): string {\n  return `<cwmp:AddObject><ObjectName>${\n    methodRequest.objectName\n  }</ObjectName><ParameterKey>${\n    methodRequest.parameterKey || \"\"\n  }</ParameterKey></cwmp:AddObject>`;\n}\n\nfunction AddObjectResponse(xml: Element): AddObjectResponse {\n  let instanceNumber: string, status: number;\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"InstanceNumber\":\n        instanceNumber = c.text;\n        break;\n      case \"Status\":\n        status = parseInt(c.text);\n        break;\n    }\n  }\n\n  if (!/^[0-9]+$/.test(instanceNumber))\n    throw new Error(\"Missing or invalid instance number\");\n\n  if (!(status >= 0)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"Status\",\n    });\n    status = 0;\n  }\n\n  return {\n    name: \"AddObjectResponse\",\n    instanceNumber: instanceNumber,\n    status: status,\n  };\n}\n\nfunction DeleteObject(methodRequest): string {\n  return `<cwmp:DeleteObject><ObjectName>${\n    methodRequest.objectName\n  }</ObjectName><ParameterKey>${\n    methodRequest.parameterKey || \"\"\n  }</ParameterKey></cwmp:DeleteObject>`;\n}\n\nfunction DeleteObjectResponse(xml: Element): DeleteObjectResponse {\n  let status: number;\n\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"Status\":\n        status = parseInt(c.text);\n        break;\n    }\n  }\n\n  if (!(status >= 0)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"Status\",\n    });\n    status = 0;\n  }\n\n  return {\n    name: \"DeleteObjectResponse\",\n    status: status,\n  };\n}\n\nfunction Reboot(methodRequest): string {\n  return `<cwmp:Reboot><CommandKey>${\n    methodRequest.commandKey || \"\"\n  }</CommandKey></cwmp:Reboot>`;\n}\n\nfunction RebootResponse(): RebootResponse {\n  return {\n    name: \"RebootResponse\",\n  };\n}\n\nfunction FactoryReset(): string {\n  return \"<cwmp:FactoryReset></cwmp:FactoryReset>\";\n}\n\nfunction FactoryResetResponse(): FactoryResetResponse {\n  return {\n    name: \"FactoryResetResponse\",\n  };\n}\n\nfunction Download(methodRequest): string {\n  return `<cwmp:Download><CommandKey>${\n    methodRequest.commandKey || \"\"\n  }</CommandKey><FileType>${methodRequest.fileType}</FileType><URL>${\n    methodRequest.url\n  }</URL><Username>${encodeEntities(\n    methodRequest.username || \"\",\n  )}</Username><Password>${encodeEntities(\n    methodRequest.password || \"\",\n  )}</Password><FileSize>${\n    methodRequest.fileSize || \"0\"\n  }</FileSize><TargetFileName>${encodeEntities(\n    methodRequest.targetFileName || \"\",\n  )}</TargetFileName><DelaySeconds>${\n    methodRequest.delaySeconds || \"0\"\n  }</DelaySeconds><SuccessURL>${encodeEntities(\n    methodRequest.successUrl || \"\",\n  )}</SuccessURL><FailureURL>${encodeEntities(\n    methodRequest.failureUrl || \"\",\n  )}</FailureURL></cwmp:Download>`;\n}\n\nfunction DownloadResponse(xml: Element): DownloadResponse {\n  let status: number, startTime: number, completeTime: number;\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"Status\":\n        status = parseInt(c.text);\n        break;\n      case \"StartTime\":\n        startTime = Date.parse(c.text);\n        break;\n      case \"CompleteTime\":\n        completeTime = Date.parse(c.text);\n        break;\n    }\n  }\n\n  if (!(status >= 0)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"Status\",\n    });\n    status = 0;\n  }\n\n  if (startTime == null || isNaN(startTime)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"StartTime\",\n    });\n    startTime = Date.parse(\"0001-01-01T00:00:00Z\");\n  }\n\n  if (completeTime == null || isNaN(completeTime)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"CompleteTime\",\n    });\n    completeTime = Date.parse(\"0001-01-01T00:00:00Z\");\n  }\n\n  return {\n    name: \"DownloadResponse\",\n    status: status,\n    startTime: startTime,\n    completeTime: completeTime,\n  };\n}\n\nfunction Inform(xml: Element): InformRequest {\n  let retryCount: number, evnt: string[];\n  let parameterList: [Path, string | number | boolean, string][];\n  const deviceId = {\n    Manufacturer: null,\n    OUI: null,\n    ProductClass: null,\n    SerialNumber: null,\n  };\n\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"ParameterList\":\n        parameterList = parameterValueList(c);\n        break;\n      case \"DeviceId\":\n        for (const cc of c.children) {\n          const n = cc.localName;\n          if (n in deviceId) deviceId[n] = decodeEntities(cc.text);\n        }\n        break;\n      case \"Event\":\n        evnt = event(c);\n        break;\n      case \"RetryCount\":\n        retryCount = parseInt(c.text);\n        break;\n    }\n  }\n\n  if (!deviceId || !deviceId.SerialNumber || !deviceId.OUI)\n    throw new Error(\"Missing or invalid DeviceId element\");\n\n  if (!parameterList) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"ParameterList\",\n    });\n    parameterList = [];\n  }\n\n  if (!evnt) {\n    warnings.push({ message: \"Missing or invalid XML node\", element: \"Event\" });\n    evnt = [];\n  }\n\n  if (retryCount == null || isNaN(retryCount)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"RetryCount\",\n    });\n    retryCount = 0;\n  }\n\n  return {\n    name: \"Inform\",\n    parameterList: parameterList,\n    deviceId: deviceId,\n    event: evnt,\n    retryCount: retryCount,\n  };\n}\n\nfunction InformResponse(): string {\n  return \"<cwmp:InformResponse><MaxEnvelopes>1</MaxEnvelopes></cwmp:InformResponse>\";\n}\n\nfunction GetRPCMethods(): GetRPCMethodsRequest {\n  return { name: \"GetRPCMethods\" };\n}\n\nfunction GetRPCMethodsResponse(methodResponse): string {\n  return `<cwmp:GetRPCMethodsResponse><MethodList soap-enc:arrayType=\"xsd:string[${\n    methodResponse.methodList.length\n  }]\">${methodResponse.methodList\n    .map((m) => `<string>${m}</string>`)\n    .join(\"\")}</MethodList></cwmp:GetRPCMethodsResponse>`;\n}\n\nfunction TransferComplete(xml: Element): TransferCompleteRequest {\n  let commandKey: string,\n    _faultStruct: FaultStruct,\n    startTime: number,\n    completeTime: number;\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"CommandKey\":\n        commandKey = c.text;\n        break;\n      case \"FaultStruct\":\n        _faultStruct = faultStruct(c);\n        break;\n      case \"StartTime\":\n        startTime = Date.parse(c.text);\n        break;\n      case \"CompleteTime\":\n        completeTime = Date.parse(c.text);\n        break;\n    }\n  }\n\n  if (commandKey == null) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"CommandKey\",\n    });\n    commandKey = \"\";\n  }\n\n  if (!_faultStruct) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"FaultStruct\",\n    });\n    _faultStruct = { faultCode: \"0\", faultString: \"\" };\n  }\n\n  if (startTime == null || isNaN(startTime)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"StartTime\",\n    });\n    startTime = Date.parse(\"0001-01-01T00:00:00Z\");\n  }\n\n  if (completeTime == null || isNaN(completeTime)) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"CompleteTime\",\n    });\n    completeTime = Date.parse(\"0001-01-01T00:00:00Z\");\n  }\n\n  return {\n    name: \"TransferComplete\",\n    commandKey: commandKey,\n    faultStruct: _faultStruct,\n    startTime: startTime,\n    completeTime: completeTime,\n  };\n}\n\nfunction TransferCompleteResponse(): string {\n  return \"<cwmp:TransferCompleteResponse></cwmp:TransferCompleteResponse>\";\n}\n\nfunction RequestDownload(xml: Element): RequestDownloadRequest {\n  return {\n    name: \"RequestDownload\",\n    fileType: xml.children.find((n) => n.localName === \"FileType\").text,\n  };\n}\n\nfunction RequestDownloadResponse(): string {\n  return \"<cwmp:RequestDownloadResponse></cwmp:RequestDownloadResponse>\";\n}\n\nfunction AcsFault(f: CpeFault): string {\n  return `<soap-env:Body:Fault><faultcode>${encodeEntities(\n    f.faultCode,\n  )}</faultcode><faultstring>${encodeEntities(\n    f.faultString,\n  )}</faultstring><detail><cwmp:Fault><FaultCode>${encodeEntities(\n    f.detail.faultCode,\n  )}</FaultCode><FaultString>${encodeEntities(\n    f.detail.faultString,\n  )}</FaultString></cwmp:Fault></detail></soap-env:Body:Fault>`;\n}\n\nfunction faultStruct(xml: Element): FaultStruct {\n  let faultCode: string,\n    faultString: string,\n    setParameterValuesFault: SpvFault[],\n    pn: string,\n    fc: string,\n    fs: string;\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"FaultCode\":\n        faultCode = c.text;\n        break;\n      case \"FaultString\":\n        faultString = decodeEntities(c.text);\n        break;\n      case \"SetParameterValuesFault\":\n        setParameterValuesFault = setParameterValuesFault || [];\n        pn = fc = fs = null;\n        for (const cc of c.children) {\n          switch (cc.localName) {\n            case \"ParameterName\":\n              pn = cc.text;\n              break;\n            case \"FaultCode\":\n              fc = cc.text;\n              break;\n            case \"FaultString\":\n              fs = decodeEntities(cc.text);\n              break;\n          }\n        }\n        setParameterValuesFault.push({\n          parameterName: pn,\n          faultCode: fc,\n          faultString: fs,\n        });\n    }\n  }\n\n  if (faultCode == null) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"FaultCode\",\n    });\n    faultCode = \"\";\n  }\n\n  if (faultString == null) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"FaultString\",\n    });\n    faultString = \"\";\n  }\n\n  return { faultCode, faultString, setParameterValuesFault };\n}\n\nfunction fault(xml: Element): CpeFault {\n  let faultCode: string, faultString: string, detail: FaultStruct;\n  for (const c of xml.children) {\n    switch (c.localName) {\n      case \"faultcode\":\n        faultCode = c.text;\n        break;\n      case \"faultstring\":\n        faultString = decodeEntities(c.text);\n        break;\n      case \"detail\":\n        detail = faultStruct(c.children.find((n) => n.localName === \"Fault\"));\n        break;\n    }\n  }\n\n  if (!detail) throw new Error(\"Missing detail element\");\n\n  if (faultCode == null) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"faultcode\",\n    });\n    faultCode = \"Client\";\n  }\n\n  if (faultString == null) {\n    warnings.push({\n      message: \"Missing or invalid XML node\",\n      element: \"faultstring\",\n    });\n    faultString = \"CWMP fault\";\n  }\n\n  return { faultCode, faultString, detail } as CpeFault;\n}\n\nexport function request(\n  body: string,\n  warn: Record<string, unknown>[],\n): SoapMessage {\n  warnings = warn;\n\n  const rpc = {\n    id: null,\n    cwmpVersion: null,\n    sessionTimeout: null,\n    cpeRequest: null,\n    cpeFault: null,\n    cpeResponse: null,\n    unknownMethod: null,\n  };\n\n  if (!body.length) return rpc;\n\n  const xml = parseXml(body);\n\n  if (!xml.children.length) return rpc;\n\n  const envelope = xml.children[0];\n\n  let headerElement: Element, bodyElement: Element;\n\n  for (const c of envelope.children) {\n    switch (c.localName) {\n      case \"Header\":\n        headerElement = c;\n        break;\n      case \"Body\":\n        bodyElement = c;\n        break;\n    }\n  }\n\n  if (headerElement) {\n    for (const c of headerElement.children) {\n      switch (c.localName) {\n        case \"ID\":\n          rpc.id = decodeEntities(c.text);\n          break;\n        case \"sessionTimeout\":\n          rpc.sessionTimeout = parseInt(c.text);\n          break;\n      }\n    }\n  }\n\n  const methodElement = bodyElement.children[0];\n\n  if (methodElement.localName === \"Inform\") {\n    let namespace, namespaceHref;\n    for (const e of [methodElement, bodyElement, envelope]) {\n      namespace = namespace || e.namespace;\n      if (e.attrs) {\n        const attrs = memoizedParseAttrs(e.attrs);\n        const attr = namespace\n          ? attrs.find(\n              (s) => s.namespace === \"xmlns\" && s.localName === namespace,\n            )\n          : attrs.find((s) => s.name === \"xmlns\");\n\n        if (attr) namespaceHref = attr.value;\n      }\n    }\n\n    switch (namespaceHref) {\n      case \"urn:dslforum-org:cwmp-1-0\":\n        rpc.cwmpVersion = \"1.0\";\n        break;\n      case \"urn:dslforum-org:cwmp-1-1\":\n        rpc.cwmpVersion = \"1.1\";\n        break;\n      case \"urn:dslforum-org:cwmp-1-2\":\n        if (rpc.sessionTimeout) rpc.cwmpVersion = \"1.3\";\n        else rpc.cwmpVersion = \"1.2\";\n\n        break;\n      case \"urn:dslforum-org:cwmp-1-3\":\n        rpc.cwmpVersion = \"1.4\";\n        break;\n      default:\n        throw new Error(\"Unrecognized CWMP version\");\n    }\n  }\n\n  switch (methodElement.localName) {\n    case \"Inform\":\n      rpc.cpeRequest = Inform(methodElement);\n      break;\n    case \"GetRPCMethods\":\n      rpc.cpeRequest = GetRPCMethods();\n      break;\n    case \"TransferComplete\":\n      rpc.cpeRequest = TransferComplete(methodElement);\n      break;\n    case \"RequestDownload\":\n      rpc.cpeRequest = RequestDownload(methodElement);\n      break;\n    case \"GetParameterNamesResponse\":\n      rpc.cpeResponse = GetParameterNamesResponse(methodElement);\n      break;\n    case \"GetParameterValuesResponse\":\n      rpc.cpeResponse = GetParameterValuesResponse(methodElement);\n      break;\n    case \"GetParameterAttributesResponse\":\n      rpc.cpeResponse = GetParameterAttributesResponse(methodElement);\n      break;\n    case \"SetParameterValuesResponse\":\n      rpc.cpeResponse = SetParameterValuesResponse(methodElement);\n      break;\n    case \"SetParameterAttributesResponse\":\n      rpc.cpeResponse = SetParameterAttributesResponse();\n      break;\n    case \"AddObjectResponse\":\n      rpc.cpeResponse = AddObjectResponse(methodElement);\n      break;\n    case \"DeleteObjectResponse\":\n      rpc.cpeResponse = DeleteObjectResponse(methodElement);\n      break;\n    case \"RebootResponse\":\n      rpc.cpeResponse = RebootResponse();\n      break;\n    case \"FactoryResetResponse\":\n      rpc.cpeResponse = FactoryResetResponse();\n      break;\n    case \"DownloadResponse\":\n      rpc.cpeResponse = DownloadResponse(methodElement);\n      break;\n    case \"Fault\":\n      rpc.cpeFault = fault(methodElement);\n      break;\n    default:\n      rpc.unknownMethod = methodElement.localName;\n      break;\n  }\n\n  return rpc;\n}\n\nconst namespacesAttrs = {\n  \"1.0\": Object.entries(NAMESPACES[\"1.0\"])\n    .map(([k, v]) => `xmlns:${k}=\"${v}\"`)\n    .join(\" \"),\n  \"1.1\": Object.entries(NAMESPACES[\"1.1\"])\n    .map(([k, v]) => `xmlns:${k}=\"${v}\"`)\n    .join(\" \"),\n  \"1.2\": Object.entries(NAMESPACES[\"1.2\"])\n    .map(([k, v]) => `xmlns:${k}=\"${v}\"`)\n    .join(\" \"),\n  \"1.3\": Object.entries(NAMESPACES[\"1.3\"])\n    .map(([k, v]) => `xmlns:${k}=\"${v}\"`)\n    .join(\" \"),\n  \"1.4\": Object.entries(NAMESPACES[\"1.4\"])\n    .map(([k, v]) => `xmlns:${k}=\"${v}\"`)\n    .join(\" \"),\n};\n\nexport function response(rpc: {\n  id: string;\n  acsRequest?: AcsRequest;\n  acsResponse?: AcsResponse;\n  acsFault?: CpeFault;\n  cwmpVersion?: string;\n}): { code: number; headers: Record<string, string>; data: string } {\n  const headers = {\n    Server: SERVER_NAME,\n    SOAPServer: SERVER_NAME,\n  };\n\n  if (!rpc) return { code: 204, headers: headers, data: \"\" };\n\n  let body;\n  if (rpc.acsResponse) {\n    switch (rpc.acsResponse.name) {\n      case \"InformResponse\":\n        body = InformResponse();\n        break;\n      case \"GetRPCMethodsResponse\":\n        body = GetRPCMethodsResponse(rpc.acsResponse);\n        break;\n      case \"TransferCompleteResponse\":\n        body = TransferCompleteResponse();\n        break;\n      case \"RequestDownloadResponse\":\n        body = RequestDownloadResponse();\n        break;\n      default:\n        throw new Error(\n          `Unknown method response type ${\n            (rpc.acsResponse as AcsResponse).name\n          }`,\n        );\n    }\n  } else if (rpc.acsRequest) {\n    switch (rpc.acsRequest.name) {\n      case \"GetParameterNames\":\n        body = GetParameterNames(rpc.acsRequest);\n        break;\n      case \"GetParameterValues\":\n        body = GetParameterValues(rpc.acsRequest);\n        break;\n      case \"GetParameterAttributes\":\n        body = GetParameterAttributes(rpc.acsRequest);\n        break;\n      case \"SetParameterValues\":\n        body = SetParameterValues(rpc.acsRequest);\n        break;\n      case \"SetParameterAttributes\":\n        body = SetParameterAttributes(rpc.acsRequest);\n        break;\n      case \"AddObject\":\n        body = AddObject(rpc.acsRequest);\n        break;\n      case \"DeleteObject\":\n        body = DeleteObject(rpc.acsRequest);\n        break;\n      case \"Reboot\":\n        body = Reboot(rpc.acsRequest);\n        break;\n      case \"FactoryReset\":\n        body = FactoryReset();\n        break;\n      case \"Download\":\n        body = Download(rpc.acsRequest);\n        break;\n      default:\n        throw new Error(\n          `Unknown method request ${(rpc.acsRequest as AcsRequest).name}`,\n        );\n    }\n  } else if (rpc.acsFault) {\n    body = AcsFault(rpc.acsFault);\n  }\n\n  headers[\"Content-Type\"] = 'text/xml; charset=\"utf-8\"';\n  return {\n    code: 200,\n    headers: headers,\n    data: `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n<soap-env:Envelope ${\n      namespacesAttrs[rpc.cwmpVersion]\n    }><soap-env:Header><cwmp:ID soap-env:mustUnderstand=\"1\">${\n      rpc.id\n    }</cwmp:ID></soap-env:Header><soap-env:Body>${body}</soap-env:Body></soap-env:Envelope>`,\n  };\n}\n"
  },
  {
    "path": "lib/types.ts",
    "content": "import { IncomingMessage, ServerResponse } from \"node:http\";\nimport { Script } from \"node:vm\";\nimport Path from \"./common/path.ts\";\nimport PathSet from \"./common/path-set.ts\";\nimport VersionedMap from \"./versioned-map.ts\";\nimport InstanceSet from \"./instance-set.ts\";\nimport Expression, { Value } from \"./common/expression.ts\";\n\nexport interface Fault {\n  code: string;\n  message: string;\n  detail?:\n    | FaultStruct\n    | {\n        name: string;\n        message: string;\n        stack?: string;\n      };\n  timestamp?: number;\n}\n\nexport interface SessionFault extends Fault {\n  timestamp: number;\n  provisions: string[][];\n  retryNow?: boolean;\n  precondition?: boolean;\n  retries?: number;\n  expiry?: number;\n}\n\nexport interface Attributes {\n  object?: [number, 1 | 0];\n  writable?: [number, 1 | 0];\n  value?: [number, [string | number | boolean, string]];\n  notification?: [number, number];\n  accessList?: [number, string[]];\n}\n\nexport interface AttributeTimestamps {\n  object?: number;\n  writable?: number;\n  value?: number;\n  notification?: number;\n  accessList?: number;\n}\n\nexport interface AttributeValues {\n  object?: boolean;\n  writable?: boolean;\n  value?: [string | number | boolean, string?];\n  notification?: number;\n  accessList?: string[];\n}\n\nexport interface DeviceData {\n  paths: PathSet;\n  timestamps: VersionedMap<Path, number>;\n  attributes: VersionedMap<Path, Attributes>;\n  trackers: Map<Path, { [name: string]: number }>;\n  changes: Set<string>;\n}\n\nexport type VirtualParameterDeclaration = [\n  Path,\n  {\n    path?: number;\n    object?: number;\n    writable?: number;\n    value?: number;\n    notification?: number;\n    accessList?: number;\n  }?,\n  {\n    path?: [number, number];\n    object?: boolean;\n    writable?: boolean;\n    value?: [string | number | boolean, string?];\n    notification?: number;\n    accessList?: string[];\n  }?,\n];\n\nexport interface SyncState {\n  refreshAttributes: {\n    exist: Set<Path>;\n    object: Set<Path>;\n    writable: Set<Path>;\n    value: Set<Path>;\n    notification: Set<Path>;\n    accessList: Set<Path>;\n  };\n  spv: Map<Path, [string | number | boolean, string]>;\n  spa: Map<Path, { notification: number; accessList: string[] }>;\n  gpn: Set<Path>;\n  gpnPatterns: Map<Path, number>;\n  tags: Map<Path, boolean>;\n  virtualParameterDeclarations: VirtualParameterDeclaration[][];\n  instancesToDelete: Map<Path, Set<Path>>;\n  instancesToCreate: Map<Path, InstanceSet>;\n  downloadsToDelete: Set<Path>;\n  downloadsToCreate: InstanceSet;\n  downloadsValues: Map<Path, string | number>;\n  downloadsDownload: Map<Path, number>;\n  reboot: number;\n  factoryReset: number;\n}\n\nexport interface SessionContext {\n  sessionId?: string;\n  timestamp: number;\n  deviceId: string;\n  deviceData: DeviceData;\n  cwmpVersion: string;\n  timeout: number;\n  provisions: any[];\n  channels: { [channel: string]: number };\n  virtualParameters: [\n    string,\n    AttributeTimestamps,\n    AttributeValues,\n    AttributeTimestamps,\n    AttributeValues,\n  ][][];\n  revisions: number[];\n  rpcCount: number;\n  iteration: number;\n  cycle: number;\n  extensionsCache: any;\n  declarations: Declaration[][];\n  faults?: { [channel: string]: SessionFault };\n  retries?: { [channel: string]: number };\n  cacheSnapshot?: string;\n  httpResponse?: ServerResponse;\n  httpRequest?: IncomingMessage;\n  faultsTouched?: { [channel: string]: boolean };\n  presetCycles?: number;\n  new?: boolean;\n  debug?: boolean;\n  state: number;\n  authState: number;\n  tasks?: Task[];\n  operations?: { [commandKey: string]: Operation };\n  syncState?: SyncState;\n  lastActivity?: number;\n  extendLock?: number;\n  rpcRequest?: AcsRequest;\n  operationsTouched?: { [commandKey: string]: 1 | 0 };\n  provisionsRet?: any[];\n  doneTasks?: string[];\n}\n\nexport interface Task {\n  _id?: string;\n  name: string;\n  parameterNames?: string[];\n  parameterValues?: [string, string | number | boolean, string?][];\n  objectName?: string;\n  fileType?: string;\n  fileName?: string;\n  targetFileName?: string;\n  expiry?: number;\n  provisions?: [string, ...Value[]][];\n}\n\nexport interface Operation {\n  name: string;\n  timestamp: number;\n  provisions: string[][];\n  channels: { [channel: string]: number };\n  retries: { [channel: string]: number };\n  args: {\n    instance: string;\n    fileType: string;\n    fileName: string;\n    targetFileName: string;\n  };\n}\n\nexport type AcsRequest =\n  | GetParameterNames\n  | GetParameterValues\n  | GetParameterAttributes\n  | SetParameterValues\n  | SetParameterAttributes\n  | AddObject\n  | DeleteObject\n  | FactoryReset\n  | Reboot\n  | Download;\n\nexport interface GetParameterNames {\n  name: \"GetParameterNames\";\n  parameterPath: string;\n  nextLevel: boolean;\n}\n\nexport interface GetParameterValues {\n  name: \"GetParameterValues\";\n  parameterNames: string[];\n}\n\nexport interface GetParameterAttributes {\n  name: \"GetParameterAttributes\";\n  parameterNames: string[];\n}\n\nexport interface SetParameterValues {\n  name: \"SetParameterValues\";\n  parameterList: [string, boolean | number | string, string][];\n  parameterKey?: string;\n  DATETIME_MILLISECONDS?: boolean;\n  BOOLEAN_LITERAL?: boolean;\n}\n\nexport interface SetParameterAttributes {\n  name: \"SetParameterAttributes\";\n  parameterList: [string, number, string[]][];\n}\n\nexport interface AddObject {\n  name: \"AddObject\";\n  objectName: string;\n  parameterKey?: string;\n  instanceValues: Record<string, string>;\n}\n\nexport interface DeleteObject {\n  name: \"DeleteObject\";\n  objectName: string;\n  parameterKey?: string;\n}\n\nexport interface FactoryReset {\n  name: \"FactoryReset\";\n  commandKey?: string;\n}\n\nexport interface Reboot {\n  name: \"Reboot\";\n  commandKey?: string;\n}\n\nexport interface Download {\n  name: \"Download\";\n  commandKey: string;\n  instance: string;\n  fileType: string;\n  fileName?: string;\n  url?: string;\n  username?: string;\n  password?: string;\n  fileSize?: number;\n  targetFileName?: string;\n  delaySecods?: number;\n  successUrl?: string;\n  failureUrl?: string;\n}\n\nexport interface SpvFault {\n  parameterName: string;\n  faultCode: string;\n  faultString: string;\n}\n\nexport interface FaultStruct {\n  faultCode: string;\n  faultString: string;\n  setParameterValuesFault?: SpvFault[];\n}\n\nexport interface CpeFault {\n  faultCode: \"Client\" | \"Server\";\n  faultString: \"CWMP fault\";\n  detail?: FaultStruct;\n}\n\nexport type CpeResponse =\n  | GetParameterNamesResponse\n  | GetParameterValuesResponse\n  | GetParameterAttributesResponse\n  | SetParameterValuesResponse\n  | SetParameterAttributesResponse\n  | AddObjectResponse\n  | DeleteObjectResponse\n  | RebootResponse\n  | FactoryResetResponse\n  | DownloadResponse;\n\nexport interface GetParameterNamesResponse {\n  name: \"GetParameterNamesResponse\";\n  parameterList: [Path, boolean, boolean][];\n}\n\nexport interface GetParameterValuesResponse {\n  name: \"GetParameterValuesResponse\";\n  parameterList: [Path, string | number | boolean, string][];\n}\n\nexport interface GetParameterAttributesResponse {\n  name: \"GetParameterAttributesResponse\";\n  parameterList: [Path, number, string[]][];\n}\n\nexport interface SetParameterValuesResponse {\n  name: \"SetParameterValuesResponse\";\n  status: number;\n}\n\nexport interface SetParameterAttributesResponse {\n  name: \"SetParameterAttributesResponse\";\n}\n\nexport interface AddObjectResponse {\n  name: \"AddObjectResponse\";\n  instanceNumber: string;\n  status: number;\n}\n\nexport interface DeleteObjectResponse {\n  name: \"DeleteObjectResponse\";\n  status: number;\n}\n\nexport interface RebootResponse {\n  name: \"RebootResponse\";\n}\n\nexport interface FactoryResetResponse {\n  name: \"FactoryResetResponse\";\n}\n\nexport interface DownloadResponse {\n  name: \"DownloadResponse\";\n  status: number;\n  startTime?: number;\n  completeTime?: number;\n}\n\nexport type CpeRequest =\n  | InformRequest\n  | TransferCompleteRequest\n  | GetRPCMethodsRequest\n  | RequestDownloadRequest;\n\nexport interface InformRequest {\n  name: \"Inform\";\n  deviceId: {\n    Manufacturer: string;\n    OUI: string;\n    ProductClass?: string;\n    SerialNumber: string;\n  };\n  event: string[];\n  retryCount: number;\n  parameterList: [Path, string | number | boolean, string][];\n}\n\nexport interface TransferCompleteRequest {\n  name: \"TransferComplete\";\n  commandKey?: string;\n  faultStruct?: FaultStruct;\n  startTime?: number;\n  completeTime?: number;\n}\n\nexport interface GetRPCMethodsRequest {\n  name: \"GetRPCMethods\";\n}\n\nexport interface RequestDownloadRequest {\n  name: \"RequestDownload\";\n  fileType: string;\n}\n\nexport type AcsResponse =\n  | InformResponse\n  | GetRPCMethodsResponse\n  | TransferCompleteResponse\n  | RequestDownloadResponse;\n\nexport interface InformResponse {\n  name: \"InformResponse\";\n}\n\nexport interface GetRPCMethodsResponse {\n  name: \"GetRPCMethodsResponse\";\n  methodList: string[];\n}\n\nexport interface TransferCompleteResponse {\n  name: \"TransferCompleteResponse\";\n}\n\nexport interface RequestDownloadResponse {\n  name: \"RequestDownloadResponse\";\n}\n\nexport interface QueryOptions {\n  projection?: any;\n  skip?: number;\n  limit?: number;\n  sort?: {\n    [param: string]: number;\n  };\n}\n\nexport interface Declaration {\n  path: Path;\n  pathGet: number;\n  pathSet?: number | [number, number];\n  attrGet?: {\n    object?: number;\n    writable?: number;\n    value?: number;\n    notification?: number;\n    accessList?: number;\n  };\n  attrSet?: {\n    object?: boolean;\n    writable?: boolean;\n    value?: [string | number | boolean, string?];\n    notification?: number;\n    accessList?: string[];\n  };\n  defer: boolean;\n}\n\nexport type Clear = [\n  Path,\n  number,\n  {\n    object?: number;\n    writable?: number;\n    value?: number;\n    notification?: number;\n    accessList?: number;\n  }?,\n  number?,\n];\n\nexport interface Preset {\n  name: string;\n  channel: string;\n  schedule?: { md5: string; duration: number; schedule: any };\n  events?: { [event: string]: boolean };\n  precondition?: Expression;\n  provisions: [string, ...Expression[]][];\n}\n\nexport interface Provisions {\n  [name: string]: { md5: string; script: Script };\n}\n\nexport interface VirtualParameters {\n  [name: string]: { md5: string; script: Script };\n}\n\nexport interface Views {\n  [name: string]: { md5: string; script: string };\n}\n\nexport interface Files {\n  [name: string]: { length: number };\n}\n\nexport interface Users {\n  [name: string]: { password: string; salt: string; roles: string[] };\n}\n\nexport interface Permissions {\n  [role: string]: {\n    [access: number]: {\n      [resource: string]: {\n        access: number;\n        filter: Expression;\n        validate: Expression;\n      };\n    };\n  };\n}\n\nexport type PermissionSet = {\n  [resource: string]: {\n    access: number;\n    validate: Expression;\n    filter: Expression;\n  };\n}[];\n\nexport interface Config {\n  [name: string]: Expression;\n}\n\nexport type UiConfig = Record<string, string>;\n\nexport interface SoapMessage {\n  id: string;\n  cwmpVersion: string;\n  sessionTimeout: number;\n  cpeRequest?: CpeRequest;\n  cpeFault?: CpeFault;\n  cpeResponse?: CpeResponse;\n  unknownMethod?: string;\n}\n\nexport interface ScriptResult {\n  fault: Fault;\n  clear: Clear[];\n  declare: Declaration[];\n  done: boolean;\n  returnValue: any;\n}\n"
  },
  {
    "path": "lib/ui/api.ts",
    "content": "import { Readable } from \"node:stream\";\nimport Router from \"@koa/router\";\nimport { ObjectId } from \"mongodb\";\nimport * as db from \"./db.ts\";\nimport * as apiFunctions from \"../api-functions.ts\";\nimport Expression, { extractPaths } from \"../common/expression.ts\";\nimport Path from \"../common/path.ts\";\nimport * as logger from \"../logger.ts\";\nimport { getConfig } from \"../ui/local-cache.ts\";\nimport { Task } from \"../types.ts\";\nimport { generateSalt, hashPassword } from \"../auth.ts\";\nimport { del } from \"../cache.ts\";\nimport Authorizer from \"../common/authorizer.ts\";\nimport { ping } from \"../ping.ts\";\nimport { decodeTag } from \"../util.ts\";\nimport { stringify as yamlStringify } from \"../common/yaml.ts\";\nimport { ResourceLockedError } from \"../common/errors.ts\";\nimport { acquireLock, releaseLock } from \"../lock.ts\";\nimport { collections } from \"../db/db.ts\";\n\nconst router = new Router();\nexport default router;\n\nfunction logUnauthorizedWarning(log): void {\n  log.message += \" not authorized\";\n  logger.accessWarn(log);\n}\n\nconst RESOURCE_DELETE = 1 << 0;\nconst RESOURCE_PUT = 1 << 1;\n\nconst RESOURCE_IDS = {\n  devices: \"DeviceID.ID\",\n  presets: \"_id\",\n  provisions: \"_id\",\n  files: \"_id\",\n  virtualParameters: \"_id\",\n  config: \"_id\",\n  permissions: \"_id\",\n  users: \"_id\",\n  faults: \"_id\",\n  tasks: \"_id\",\n  views: \"_id\",\n};\n\nconst resources = {\n  devices: 0 | RESOURCE_DELETE,\n  presets: 0 | RESOURCE_DELETE | RESOURCE_PUT,\n  provisions: 0 | RESOURCE_DELETE | RESOURCE_PUT,\n  files: 0 | RESOURCE_DELETE,\n  virtualParameters: 0 | RESOURCE_DELETE | RESOURCE_PUT,\n  config: 0 | RESOURCE_DELETE | RESOURCE_PUT,\n  permissions: 0 | RESOURCE_DELETE | RESOURCE_PUT,\n  users: 0 | RESOURCE_DELETE | RESOURCE_PUT,\n  faults: 0 | RESOURCE_DELETE,\n  tasks: 0,\n  views: 0 | RESOURCE_DELETE | RESOURCE_PUT,\n};\n\nfunction singleParam(p: string | string[]): string {\n  return Array.isArray(p) ? p[p.length - 1] : p;\n}\n\nrouter.get(`/devices/:id.csv`, async (ctx) => {\n  const authorizer: Authorizer = ctx.state.authorizer;\n  const log = {\n    message: \"Query device (CSV)\",\n    context: ctx,\n    id: ctx.params.id,\n  };\n\n  const filter = Expression.and(\n    authorizer.getFilter(\"devices\", 2),\n    new Expression.Binary(\n      \"=\",\n      new Expression.Parameter(Path.parse(RESOURCE_IDS.devices)),\n      new Expression.Literal(ctx.params.id),\n    ),\n  );\n\n  if (!authorizer.hasAccess(\"devices\", 2)) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n\n  const { value: device } = await db.query(\"devices\", filter).next();\n  if (!device) return void (ctx.status = 404);\n\n  ctx.type = \"text/csv\";\n  ctx.attachment(\n    `device-${ctx.params.id}-${new Date()\n      .toISOString()\n      .replace(/[:.]/g, \"\")}.csv`,\n  );\n\n  const lines: string[] = [\n    \"Parameter,Object,Writable,Value,Value type,Timestamp,Value timestamp,Notification,Access list,Attributes timestamp\",\n  ];\n\n  const keys = Object.keys(device).sort();\n  let prevParam = \"\";\n  let attrs: Record<string, string | number | boolean | null> = {};\n\n  function flushRow(): void {\n    if (!prevParam) return;\n    let value: string | number | boolean | null = attrs[\"value\"] ?? \"\";\n    if (attrs[\"type\"] === \"xsd:dateTime\" && typeof value === \"number\")\n      value = new Date(value).toJSON();\n\n    const row = [\n      prevParam,\n      attrs[\"object\"] ?? \"\",\n      attrs[\"writable\"] ?? \"\",\n      `\"${String(value).replace(/\"/g, '\"\"')}\"`,\n      attrs[\"type\"] ?? \"\",\n      attrs[\"timestamp\"] != null ? new Date(+attrs[\"timestamp\"]).toJSON() : \"\",\n      attrs[\"valueTimestamp\"] != null\n        ? new Date(+attrs[\"valueTimestamp\"]).toJSON()\n        : \"\",\n      attrs[\"notification\"] ?? \"\",\n      attrs[\"accessList\"] ?? \"\",\n      attrs[\"attributesTimestamp\"] != null\n        ? new Date(+attrs[\"attributesTimestamp\"]).toJSON()\n        : \"\",\n    ];\n    lines.push(row.map((r) => (r != null ? r : \"\")).join(\",\"));\n  }\n\n  for (const k of keys) {\n    const colonIdx = k.lastIndexOf(\":\");\n    const param = colonIdx === -1 ? k : k.slice(0, colonIdx);\n    const attr = colonIdx === -1 ? \"value\" : k.slice(colonIdx + 1);\n\n    if (param !== prevParam) {\n      flushRow();\n      prevParam = param;\n      attrs = {};\n    }\n    attrs[attr] = device[k];\n  }\n  flushRow();\n  ctx.body = lines.join(\"\\n\");\n  logger.accessInfo(log);\n});\n\nfor (const [resource, flags] of Object.entries(resources)) {\n  router.head(`/${resource}`, async (ctx) => {\n    const authorizer: Authorizer = ctx.state.authorizer;\n    let filter: Expression = authorizer.getFilter(resource, 1);\n    if (ctx.request.query.filter)\n      filter = Expression.and(\n        filter,\n        Expression.parse(singleParam(ctx.request.query.filter)),\n      );\n\n    const log = {\n      message: `Count ${resource}`,\n      context: ctx,\n      filter: ctx.request.query.filter,\n      count: null,\n    };\n\n    if (!authorizer.hasAccess(resource, 1)) {\n      logUnauthorizedWarning(log);\n      return void (ctx.status = 403);\n    }\n\n    // Exclude temporary tasks and faults\n    if (resource === \"tasks\" || resource === \"faults\") {\n      const p = new Expression.Parameter(Path.parse(\"expiry\"));\n      filter = Expression.and(\n        filter,\n        Expression.or(\n          new Expression.Binary(\n            \">=\",\n            p,\n            new Expression.Literal(Date.now() + 60000),\n          ),\n          new Expression.Unary(\"IS NULL\", p),\n        ),\n      );\n    }\n\n    const count = await db.count(resource, filter);\n\n    ctx.set(\"X-Total-Count\", `${count}`);\n    ctx.body = \"\";\n\n    log.count = count;\n    logger.accessInfo(log);\n  });\n\n  router.get(`/${resource}`, async (ctx) => {\n    const authorizer: Authorizer = ctx.state.authorizer;\n    const options: Parameters<typeof db.query>[2] = {};\n    let filter: Expression = authorizer.getFilter(resource, 2);\n    if (ctx.request.query.filter)\n      filter = Expression.and(\n        filter,\n        Expression.parse(singleParam(ctx.request.query.filter)),\n      );\n    if (ctx.request.query.limit) options.limit = +ctx.request.query.limit;\n    if (ctx.request.query.skip) options.skip = +ctx.request.query.skip;\n    if (ctx.request.query.sort)\n      options.sort = JSON.parse(singleParam(ctx.request.query.sort));\n    if (ctx.request.query.projection) {\n      options.projection = singleParam(ctx.request.query.projection)\n        .split(\",\")\n        .reduce((obj, k) => Object.assign(obj, { [k]: 1 }), {});\n    }\n\n    const log = {\n      message: `Query ${resource}`,\n      context: ctx,\n      filter: ctx.request.query.filter,\n      limit: options.limit,\n      skip: options.skip,\n      sort: options.sort,\n      projection: options.projection,\n    };\n\n    if (!authorizer.hasAccess(resource, 2)) {\n      logUnauthorizedWarning(log);\n      return void (ctx.status = 403);\n    }\n\n    // Exclude temporary tasks and faults\n    if (resource === \"tasks\" || resource === \"faults\") {\n      const p = new Expression.Parameter(Path.parse(\"expiry\"));\n      filter = Expression.and(\n        filter,\n        Expression.or(\n          new Expression.Binary(\n            \">=\",\n            p,\n            new Expression.Literal(Date.now() + 60000),\n          ),\n          new Expression.Unary(\"IS NULL\", p),\n        ),\n      );\n    }\n\n    logger.accessInfo(log);\n    ctx.type = \"application/json\";\n    ctx.body = Readable.from(\n      (async function* () {\n        let c = 0;\n        yield \"[\\n\";\n        for await (const obj of db.query(resource, filter, options))\n          yield (c++ ? \",\" : \"\") + JSON.stringify(obj) + \"\\n\";\n        yield \"]\";\n      })(),\n    );\n  });\n\n  // CSV download\n  router.get(`/${resource}.csv`, async (ctx) => {\n    const authorizer: Authorizer = ctx.state.authorizer;\n    const options: Parameters<typeof db.query>[2] = { projection: {} };\n    let filter: Expression = authorizer.getFilter(resource, 2);\n    if (ctx.request.query.filter)\n      filter = Expression.and(\n        filter,\n        Expression.parse(singleParam(ctx.request.query.filter)),\n      );\n    if (ctx.request.query.limit) options.limit = +ctx.request.query.limit;\n    if (ctx.request.query.skip) options.skip = +ctx.request.query.skip;\n    if (ctx.request.query.sort)\n      options.sort = JSON.parse(singleParam(ctx.request.query.sort));\n\n    const log = {\n      message: `Query ${resource} (CSV)`,\n      context: ctx,\n      filter: ctx.request.query.filter,\n      limit: options.limit,\n      skip: options.skip,\n      sort: options.sort,\n    };\n\n    if (!authorizer.hasAccess(resource, 2)) {\n      logUnauthorizedWarning(log);\n      return void (ctx.status = 403);\n    }\n\n    const columnsStr: Record<string, string> = JSON.parse(\n      singleParam(ctx.request.query.columns),\n    );\n\n    const now = Date.now();\n    const columns: Record<string, Expression> = Object.fromEntries(\n      Object.entries(columnsStr).map(([k, v]) => {\n        let exp = Expression.parse(v);\n        exp = exp.evaluate((e) => {\n          if (e instanceof Expression.FunctionCall && e.name === \"NOW\")\n            return new Expression.Literal(now);\n          return e;\n        });\n        for (const p of extractPaths(exp)) options.projection[p.toString()] = 1;\n        return [k, exp];\n      }),\n    );\n\n    // Exclude temporary tasks and faults\n    if (resource === \"tasks\" || resource === \"faults\") {\n      const p = new Expression.Parameter(Path.parse(\"expiry\"));\n      filter = Expression.and(\n        filter,\n        Expression.or(\n          new Expression.Binary(\n            \">=\",\n            p,\n            new Expression.Literal(Date.now() + 60000),\n          ),\n          new Expression.Unary(\"IS NULL\", p),\n        ),\n      );\n    }\n\n    logger.accessInfo(log);\n    ctx.type = \"text/csv\";\n    ctx.attachment(\n      `${resource}-${new Date(now).toISOString().replace(/[:.]/g, \"\")}.csv`,\n    );\n\n    ctx.body = Readable.from(\n      (async function* () {\n        yield Object.keys(columns).map((k) => `\"${k.replace(/\"/, '\"\"')}\"`) +\n          \"\\n\";\n        for await (const obj of db.query(resource, filter, options)) {\n          const arr = Object.values(columns).map((exp) => {\n            return exp.evaluate((e) => {\n              if (e instanceof Expression.Literal) return e;\n              else if (e instanceof Expression.FunctionCall) {\n                if (e.name === \"NOW\") return new Expression.Literal(now);\n                if (e.name === \"DATE_STRING\") {\n                  if (e.args[0] instanceof Expression.Literal)\n                    return new Expression.Literal(\n                      new Date(e.args[0].value as number).toJSON(),\n                    );\n                }\n              } else if (e instanceof Expression.Parameter) {\n                let v = obj[e.path.toString()];\n                if (resource === \"devices\") {\n                  if (e.path.toString() === \"Tags\") {\n                    const tags = [];\n                    for (const p in obj)\n                      if (p.startsWith(\"Tags.\") && p.lastIndexOf(\":\") === -1)\n                        tags.push(decodeTag(p.slice(5)));\n                    v = tags.join(\", \");\n                  }\n                  if (e === exp) {\n                    const type = obj[e.path.toString() + \":type\"];\n                    if (type === \"xsd:dateTime\" && typeof v === \"number\")\n                      v = new Date(v).toJSON();\n                  }\n                } else if (resource === \"faults\") {\n                  if (e.path.toString() === \"detail\") v = yamlStringify(v);\n                }\n\n                if (typeof v === \"string\") v = `\"${v.replace(/\"/g, '\"\"')}\"`;\n                if (v != null) return new Expression.Literal(v);\n              }\n              return new Expression.Literal(null);\n            }).value;\n          });\n          yield arr.join(\",\") + \"\\n\";\n        }\n      })(),\n    );\n  });\n\n  router.head(`/${resource}/:id`, async (ctx) => {\n    const authorizer: Authorizer = ctx.state.authorizer;\n    const log = {\n      message: `Count ${resource}`,\n      context: ctx,\n      filter: `${RESOURCE_IDS[resource]} = \"${ctx.params.id}\"`,\n    };\n\n    const filter = Expression.and(\n      authorizer.getFilter(resource, 2),\n      new Expression.Binary(\n        \"=\",\n        new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),\n        new Expression.Literal(ctx.params.id),\n      ),\n    );\n\n    if (!authorizer.hasAccess(resource, 2)) {\n      logUnauthorizedWarning(log);\n      return void (ctx.status = 403);\n    }\n\n    const count = await db.count(resource, filter);\n\n    if (!count) return void (ctx.status = 404);\n\n    logger.accessInfo(log);\n    ctx.body = \"\";\n  });\n\n  router.get(`/${resource}/:id`, async (ctx) => {\n    const authorizer: Authorizer = ctx.state.authorizer;\n    const log = {\n      message: `Query ${resource}`,\n      context: ctx,\n      filter: `${RESOURCE_IDS[resource]} = \"${ctx.params.id}\"`,\n    };\n\n    const filter = Expression.and(\n      authorizer.getFilter(resource, 2),\n      new Expression.Binary(\n        \"=\",\n        new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),\n        new Expression.Literal(ctx.params.id),\n      ),\n    );\n    if (!authorizer.hasAccess(resource, 2)) {\n      logUnauthorizedWarning(log);\n      return void (ctx.status = 403);\n    }\n\n    const { value: res } = await db.query(resource, filter).next();\n\n    if (!res) return void (ctx.status = 404);\n\n    logger.accessInfo(log);\n    ctx.body = res;\n  });\n\n  if (flags & RESOURCE_DELETE) {\n    router.delete(`/${resource}/:id`, async (ctx) => {\n      const authorizer: Authorizer = ctx.state.authorizer;\n      const log = {\n        message: `Delete ${resource}`,\n        context: ctx,\n        id: ctx.params.id,\n      };\n\n      const filter = Expression.and(\n        authorizer.getFilter(resource, 3),\n        new Expression.Binary(\n          \"=\",\n          new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),\n          new Expression.Literal(ctx.params.id),\n        ),\n      );\n      if (!authorizer.hasAccess(resource, 3)) {\n        logUnauthorizedWarning(log);\n        return void (ctx.status = 403);\n      }\n      const { value: res } = await db.query(resource, filter).next();\n      if (!res) return void (ctx.status = 404);\n\n      const validate = authorizer.getValidator(resource, res);\n      if (!validate(\"delete\")) {\n        logUnauthorizedWarning(log);\n        return void (ctx.status = 403);\n      }\n\n      try {\n        await apiFunctions.deleteResource(resource, ctx.params.id);\n      } catch (err) {\n        if (err instanceof ResourceLockedError) {\n          log.message += \" failed\";\n          logger.accessWarn(log);\n          ctx.status = 503;\n          ctx.body = err.message;\n          return;\n        }\n        throw err;\n      }\n\n      logger.accessInfo(log);\n\n      ctx.body = \"\";\n    });\n  }\n\n  if (flags & RESOURCE_PUT) {\n    router.put(`/${resource}/:id`, async (ctx) => {\n      const authorizer: Authorizer = ctx.state.authorizer;\n      const id = ctx.params.id;\n\n      const log = {\n        message: `Put ${resource}`,\n        context: ctx,\n        id: id,\n      };\n\n      if (!authorizer.hasAccess(resource, 3)) {\n        logUnauthorizedWarning(log);\n        return void (ctx.status = 403);\n      }\n\n      const obj = ctx.request.body;\n\n      const validate = authorizer.getValidator(resource, obj);\n      if (!validate(\"put\")) {\n        logUnauthorizedWarning(log);\n        return void (ctx.status = 403);\n      }\n\n      try {\n        await apiFunctions.putResource(resource, id, obj);\n      } catch (err) {\n        log.message += \" failed\";\n        logger.accessWarn(log);\n        ctx.body = `${err.name}: ${err.message}`;\n        return void (ctx.status = 400);\n      }\n\n      logger.accessInfo(log);\n\n      ctx.body = \"\";\n    });\n  }\n}\n\nrouter.get(\"/blob/files/:id\", async (ctx) => {\n  const authorizer: Authorizer = ctx.state.authorizer;\n  const resource = \"files\";\n  const id = ctx.params.id;\n\n  const log = {\n    message: `Download ${resource}`,\n    context: ctx,\n    id: id,\n  };\n\n  const filter = Expression.and(\n    authorizer.getFilter(resource, 2),\n    new Expression.Binary(\n      \"=\",\n      new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),\n      new Expression.Literal(ctx.params.id),\n    ),\n  );\n\n  if (!authorizer.hasAccess(resource, 2)) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n\n  const count = await db.count(resource, filter);\n  if (!count) return void (ctx.status = 404);\n\n  logger.accessInfo(log);\n  ctx.body = db.downloadFile(id);\n  ctx.attachment(id);\n});\n\nrouter.put(\"/files/:id\", async (ctx) => {\n  const authorizer: Authorizer = ctx.state.authorizer;\n  const resource = \"files\";\n  const id = ctx.params.id;\n\n  const log = {\n    message: `Upload ${resource}`,\n    context: ctx,\n    id: id,\n    metadata: null,\n  };\n\n  if (!authorizer.hasAccess(resource, 3)) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n\n  const metadata = {\n    fileType: singleParam(ctx.request.headers[\"metadata-filetype\"]) || \"\",\n    oui: singleParam(ctx.request.headers[\"metadata-oui\"]) || \"\",\n    productClass:\n      singleParam(ctx.request.headers[\"metadata-productclass\"]) || \"\",\n    version: singleParam(ctx.request.headers[\"metadata-version\"]) || \"\",\n  };\n\n  const validate = authorizer.getValidator(resource, metadata);\n  if (!validate(\"put\")) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n\n  try {\n    await db.deleteFile(id);\n  } catch {\n    // File doesn't exist, ignore\n  }\n\n  await db.putFile(id, metadata, ctx.req);\n  log.metadata = metadata;\n  logger.accessInfo(log);\n\n  ctx.body = \"\";\n});\n\nrouter.post(\"/devices/:id/tasks\", async (ctx) => {\n  const deviceId = ctx.params.id;\n  const authorizer: Authorizer = ctx.state.authorizer;\n  const log = {\n    message: \"Commit tasks\",\n    context: ctx,\n    deviceId: deviceId,\n    tasks: null,\n  };\n\n  if (!authorizer.hasAccess(\"devices\", 3)) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n\n  const socketTimeout: number = ctx.socket.timeout;\n\n  // Extend socket timeout while waiting for session\n  if (socketTimeout) ctx.socket.setTimeout(300000);\n\n  const token = await acquireLock(`cwmp_session_${deviceId}`, 5000, 30000);\n  if (!token) {\n    log.message += \" failed\";\n    logger.accessWarn(log);\n    ctx.body = \"Device is in session\";\n    ctx.status = 503;\n    // Restore socket timeout\n    if (socketTimeout) ctx.socket.setTimeout(socketTimeout);\n    return;\n  }\n\n  let device;\n\n  let statuses: { _id: string; status: string }[];\n\n  try {\n    const filter = Expression.and(\n      authorizer.getFilter(\"devices\", 3),\n      new Expression.Binary(\n        \"=\",\n        new Expression.Parameter(Path.parse(RESOURCE_IDS[\"devices\"])),\n        new Expression.Literal(ctx.params.id),\n      ),\n    );\n    device = (await db.query(\"devices\", filter).next()).value;\n    if (!device) return void (ctx.status = 404);\n\n    const validate = authorizer.getValidator(\"devices\", device);\n    for (const t of ctx.request.body) {\n      if (!validate(\"task\", t)) {\n        logUnauthorizedWarning(log);\n        return void (ctx.status = 403);\n      }\n    }\n\n    let tasks = ctx.request.body as Task[];\n\n    for (const task of tasks) {\n      delete task._id;\n      task[\"device\"] = deviceId;\n    }\n\n    tasks = await apiFunctions.insertTasks(tasks);\n\n    statuses = tasks.map((t) => ({ _id: t._id, status: \"pending\" }));\n  } finally {\n    await releaseLock(`cwmp_session_${deviceId}`, token);\n  }\n\n  const now = Date.now();\n\n  const onlineThreshold = getConfig(\n    ctx.state.configSnapshot,\n    \"cwmp.deviceOnlineThreshold\",\n    4000,\n    (exp) => {\n      if (exp instanceof Expression.Literal) return exp;\n      else if (exp instanceof Expression.Parameter) {\n        const p = device[exp.path.toString()];\n        if (p != null) return new Expression.Literal(p);\n      } else if (exp instanceof Expression.FunctionCall) {\n        if (exp.name === \"NOW\") return new Expression.Literal(now);\n        if (exp.name === \"REMOTE_ADDRESS\") {\n          for (const root of [\"InternetGatewayDevice\", \"Device\"]) {\n            const p = device[`${root}.ManagementServer.ConnectionRequestURL`];\n            if (p != null) return new Expression.Literal(new URL(p).hostname);\n          }\n        }\n      }\n      return new Expression.Literal(null);\n    },\n  );\n\n  const lastInform = device[\"Events.Inform\"] as number;\n\n  let status = await apiFunctions.connectionRequest(deviceId, device);\n  if (!status) {\n    const sessionStarted = await apiFunctions.awaitSessionStart(\n      deviceId,\n      lastInform,\n      onlineThreshold,\n    );\n    if (!sessionStarted) {\n      status = \"No contact from CPE\";\n    } else {\n      const sessionEnded = await apiFunctions.awaitSessionEnd(deviceId, 120000);\n      if (!sessionEnded) status = \"Session took too long to complete\";\n    }\n  }\n\n  if (!status) {\n    const promises = statuses.map((t) =>\n      collections.faults.count({ _id: `${deviceId}:task_${t._id}` }),\n    );\n\n    const res = await Promise.all(promises);\n    for (const [i, r] of statuses.entries())\n      r.status = res[i] ? \"fault\" : \"done\";\n  }\n\n  await Promise.all(statuses.map((t) => db.deleteTask(new ObjectId(t._id))));\n\n  // Restore socket timeout\n  if (socketTimeout) ctx.socket.setTimeout(socketTimeout);\n\n  log.tasks = statuses.map((t) => t._id).join(\",\");\n  logger.accessInfo(log);\n\n  ctx.set(\"Connection-Request\", status || \"OK\");\n  ctx.body = statuses;\n});\n\nrouter.post(\"/devices/:id/tags\", async (ctx) => {\n  const authorizer: Authorizer = ctx.state.authorizer;\n  const log = {\n    message: \"Update tags\",\n    context: ctx,\n    deviceId: ctx.params.id,\n    tags: ctx.request.body,\n  };\n\n  const filter = Expression.and(\n    authorizer.getFilter(\"devices\", 3),\n    new Expression.Binary(\n      \"=\",\n      new Expression.Parameter(Path.parse(RESOURCE_IDS[\"devices\"])),\n      new Expression.Literal(ctx.params.id),\n    ),\n  );\n  if (!authorizer.hasAccess(\"devices\", 3)) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n  const { value: res } = await db.query(\"devices\", filter).next();\n  if (!res) return void (ctx.status = 404);\n\n  const validate = authorizer.getValidator(\"devices\", res);\n  if (!validate(\"tags\", ctx.request.body)) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n\n  try {\n    await db.updateDeviceTags(ctx.params.id, ctx.request.body);\n  } catch (error) {\n    log.message += \" failed\";\n    logger.accessWarn(log);\n    ctx.body = error.message;\n    return void (ctx.status = 400);\n  }\n\n  logger.accessInfo(log);\n\n  ctx.body = \"\";\n});\n\nrouter.get(\"/ping/:host\", async (ctx) => {\n  if (!ctx.state.user) return void (ctx.status = 401);\n  return new Promise<void>((resolve) => {\n    ping(ctx.params.host, (err, parsed) => {\n      if (parsed) {\n        ctx.body = parsed;\n      } else {\n        ctx.status = 500;\n        ctx.body = err ? `${err.name}: ${err.message}` : \"Unknown error\";\n      }\n      resolve();\n    });\n  });\n});\n\nrouter.put(\"/users/:id/password\", async (ctx) => {\n  const authorizer: Authorizer = ctx.state.authorizer;\n  const username = ctx.params.id;\n  const log = {\n    message: \"Change password\",\n    context: ctx,\n    username: username,\n  };\n\n  if (!ctx.state.user) {\n    // User not logged in\n    if (\n      !(await apiFunctions.authLocal(\n        ctx.state.configSnapshot,\n        username,\n        ctx.request.body.authPassword,\n      ))\n    ) {\n      logUnauthorizedWarning(log);\n      ctx.status = 401;\n      ctx.body = \"Authentication failed, check your username and password\";\n      return;\n    }\n  } else if (!authorizer.hasAccess(\"users\", 3)) {\n    logUnauthorizedWarning(log);\n    return void (ctx.status = 403);\n  }\n\n  const newPassword = ctx.request.body.newPassword;\n\n  if (ctx.state.user) {\n    const filter = Expression.and(\n      authorizer.getFilter(\"users\", 3),\n      new Expression.Binary(\n        \"=\",\n        new Expression.Parameter(Path.parse(RESOURCE_IDS[\"users\"])),\n        new Expression.Literal(username),\n      ),\n    );\n    const { value: res } = await db.query(\"users\", filter).next();\n    if (!res) return void (ctx.status = 404);\n    const validate = authorizer.getValidator(\"users\", res);\n    if (!validate(\"password\", { password: newPassword })) {\n      logUnauthorizedWarning(log);\n      return void (ctx.status = 403);\n    }\n  }\n\n  const salt = await generateSalt(64);\n  const password = await hashPassword(newPassword, salt);\n  await db.putUser(username, { password, salt });\n\n  await del(\"ui-local-cache-hash\");\n\n  logger.accessInfo(log);\n  ctx.body = \"\";\n});\n"
  },
  {
    "path": "lib/ui/db.ts",
    "content": "import { Script } from \"node:vm\";\nimport { Readable } from \"node:stream\";\nimport { Collection, ObjectId, WithoutId } from \"mongodb\";\nimport { encodeTag } from \"../util.ts\";\nimport { Fault, Task } from \"../types.ts\";\nimport { collections, filesBucket } from \"../db/db.ts\";\nimport { validateViewScript } from \"../bundle-views.ts\";\nimport { convertOldPrecondition, optimizeProjection } from \"../db/util.ts\";\nimport * as MongoTypes from \"../db/types.ts\";\nimport Expression, { parseList, Value } from \"../common/expression.ts\";\nimport { toMongoQuery } from \"../db/synth.ts\";\n\nfunction processDeviceProjection(\n  projection: Record<string, 1>,\n): Record<string, 1> {\n  if (!projection) return projection;\n  const p = {};\n  for (const [k, v] of Object.entries(projection)) {\n    if (k === \"DeviceID.ID\") {\n      p[\"_id\"] = 1;\n    } else if (k.startsWith(\"DeviceID\")) {\n      p[\"_deviceId._SerialNumber\"] = v;\n      p[\"_deviceId._OUI\"] = v;\n      p[\"_deviceId._ProductClass\"] = v;\n      p[\"_deviceId._Manufacturer\"] = v;\n    } else if (k.startsWith(\"Tags\")) {\n      p[\"_tags\"] = v;\n    } else if (k.startsWith(\"Events\")) {\n      p[\"_lastInform\"] = v;\n      p[\"_registered\"] = v;\n      p[\"_lastBoot\"] = v;\n      p[\"_lastBootstrap\"] = v;\n    } else {\n      p[k] = v;\n    }\n  }\n\n  return p;\n}\n\nfunction processDeviceSort(\n  sort: Record<string, number>,\n): Record<string, number> {\n  if (!sort) return sort;\n  const s = {};\n  for (const [k, v] of Object.entries(sort)) {\n    if (k === \"DeviceID.ID\") s[\"_id\"] = v;\n    else if (k.startsWith(\"DeviceID.\")) s[`_deviceId._${k.slice(9)}`] = v;\n    else if (k === \"Events.Inform\") s[\"_lastInform\"] = v;\n    else if (k === \"Events.Registered\") s[\"_registered\"] = v;\n    else if (k === \"Events.1_BOOT\") s[\"_lastBoot\"] = v;\n    else if (k === \"Events.0_BOOTSTRAP\") s[\"_lastBootstrap\"] = v;\n    else s[`${k}._value`] = v;\n  }\n\n  return s;\n}\n\nfunction parseDate(d: Date): number | string {\n  const n = +d;\n  return isNaN(n) ? \"\" + d : n;\n}\n\nexport interface FlatDevice {\n  [param: string]: Value;\n}\n\nexport function flattenDevice(device: Record<string, unknown>): FlatDevice {\n  function recursive(\n    input,\n    root: string,\n    output: FlatDevice,\n    timestamp: number,\n  ): void {\n    for (const [name, tree] of Object.entries(input)) {\n      if (!root) {\n        if (name === \"_lastInform\") {\n          output[\"Events.Inform\"] = parseDate(tree as Date);\n          output[\"Events.Inform:type\"] = \"xsd:dateTime\";\n        } else if (name === \"_registered\") {\n          output[\"Events.Registered\"] = parseDate(tree as Date);\n          output[\"Events.Registered:type\"] = \"xsd:dateTime\";\n        } else if (name === \"_lastBoot\") {\n          output[\"Events.1_BOOT\"] = parseDate(tree as Date);\n          output[\"Events.1_BOOT:type\"] = \"xsd:dateTime\";\n        } else if (name === \"_lastBootstrap\") {\n          output[\"Events.0_BOOTSTRAP\"] = parseDate(tree as Date);\n          output[\"Events.0_BOOTSTRAP:type\"] = \"xsd:dateTime\";\n        } else if (name === \"_id\") {\n          output[\"DeviceID.ID\"] = tree as string;\n          output[\"DeviceID.ID:type\"] = \"xsd:dateTime\";\n        } else if (name === \"_deviceId\") {\n          output[\"DeviceID.Manufacturer\"] = tree[\"_Manufacturer\"];\n          output[\"DeviceID.Manufacturer:type\"] = \"xsd:string\";\n          output[\"DeviceID.OUI\"] = tree[\"_OUI\"];\n          output[\"DeviceID.OUI:type\"] = \"xsd:string\";\n          output[\"DeviceID.ProductClass\"] = tree[\"_ProductClass\"];\n          output[\"DeviceID.ProductClass:type\"] = \"xsd:string\";\n          output[\"DeviceID.SerialNumber\"] = tree[\"_SerialNumber\"];\n          output[\"DeviceID.SerialNumber:type\"] = \"xsd:string\";\n        } else if (name === \"_tags\") {\n          output[\"Tags:object\"] = true;\n          output[\"Tags:writable\"] = true;\n\n          for (const t of tree as string[]) {\n            const et = encodeTag(t);\n            output[`Tags.${et}`] = true;\n            output[`Tags.${et}:type`] = \"xsd:boolean\";\n            output[`Tags.${et}:writable`] = true;\n          }\n        }\n      }\n\n      if (name.startsWith(\"_\")) continue;\n\n      let childrenTimestamp = timestamp;\n\n      if (!root) childrenTimestamp = +(input[\"_timestamp\"] || 1);\n      else if (+input[\"_timestamp\"] > timestamp)\n        childrenTimestamp = +input[\"_timestamp\"];\n\n      const r = root ? `${root}.${name}` : name;\n\n      if (tree[\"_value\"] != null) {\n        output[r] =\n          tree[\"_value\"] instanceof Date ? +tree[\"_value\"] : tree[\"_value\"];\n        output[`${r}:type`] = tree[\"_type\"];\n        output[`${r}:timestamp`] = childrenTimestamp;\n        output[`${r}:valueTimestamp`] = +(\n          tree[\"_timestamp\"] || childrenTimestamp\n        );\n      } else if (tree[\"_object\"] != null) {\n        output[`${r}:object`] = tree[\"_object\"];\n        output[`${r}:timestamp`] = childrenTimestamp;\n      }\n\n      if (tree[\"_writable\"] != null) {\n        output[`${r}:writable`] = tree[\"_writable\"];\n        output[`${r}:timestamp`] = childrenTimestamp;\n      }\n\n      if (tree[\"_notification\"] != null) {\n        output[`${r}:notification`] = tree[\"_notification\"];\n        output[`${r}:attributesTimestamp`] = +tree[\"_attributesTimestamp\"] || 1;\n      }\n\n      if (tree[\"_accessList\"] != null) {\n        output[`${r}:accessList`] = tree[\"_accessList\"].join(\",\");\n        output[`${r}:attributesTimestamp`] = +tree[\"_attributesTimestamp\"] || 1;\n      }\n\n      recursive(tree, r, output, childrenTimestamp);\n    }\n  }\n\n  const newDevice: FlatDevice = {};\n  const timestamp = new Date((device[\"_lastInform\"] as Date) || 1).getTime();\n  recursive(device, \"\", newDevice, timestamp);\n  return newDevice;\n}\n\nfunction flattenFault(fault: unknown): Fault {\n  const f = Object.assign({}, fault) as Fault;\n  if (f.timestamp) f.timestamp = +f.timestamp;\n  if (f[\"expiry\"]) f[\"expiry\"] = +f[\"expiry\"];\n  return f as Fault;\n}\n\nfunction flattenTask(task: unknown): Task {\n  const t = Object.assign({}, task) as Task;\n  t._id = \"\" + t._id;\n  if (t[\"timestamp\"]) t[\"timestamp\"] = +t[\"timestamp\"];\n  if (t.expiry) t.expiry = +t.expiry;\n  return t;\n}\n\nfunction flattenPreset(\n  preset: Record<string, unknown>,\n): Record<string, unknown> {\n  const p = Object.assign({}, preset);\n  if (p.precondition) {\n    try {\n      // Try parse to check expression validity\n      Expression.parse(p.precondition as string);\n    } catch {\n      const e = convertOldPrecondition(JSON.parse(p.precondition as string));\n      if (e instanceof Expression.Literal && e.value)\n        p.precondition = e.toString();\n      else p.precondition = \"\";\n    }\n  }\n\n  if (p.events) {\n    const e = [];\n    for (const [k, v] of Object.entries(p.events)) e.push(v ? k : `-${k}`);\n    p.events = e.join(\", \");\n  }\n\n  const provision = p.configurations[0];\n  if (\n    (p.configurations as any[]).length === 1 &&\n    provision.type === \"provision\" &&\n    provision.name &&\n    provision.name.length\n  ) {\n    p.provision = provision.name;\n    p.provisionArgs = provision.args\n      ? provision.args.map((a) => a.toString()).join(\", \")\n      : \"\";\n  }\n\n  delete p.configurations;\n  return p;\n}\n\nfunction flattenFile(file: Record<string, unknown>): Record<string, unknown> {\n  const f = {};\n  f[\"_id\"] = file[\"_id\"];\n  if (file.metadata) {\n    f[\"metadata.fileType\"] = file[\"metadata\"][\"fileType\"] || \"\";\n    f[\"metadata.oui\"] = file[\"metadata\"][\"oui\"] || \"\";\n    f[\"metadata.productClass\"] = file[\"metadata\"][\"productClass\"] || \"\";\n    f[\"metadata.version\"] = file[\"metadata\"][\"version\"] || \"\";\n  }\n  return f;\n}\n\nfunction preProcessPreset(data: Record<string, unknown>): MongoTypes.Preset {\n  const preset = Object.assign({}, data);\n\n  if (!preset.precondition) preset.precondition = \"\";\n  else Expression.parse(preset.precondition as string); // Try parse to check validity\n\n  preset.weight = parseInt(preset.weight as string) || 0;\n\n  const events = {};\n  if (preset.events) {\n    for (let e of (preset.events as string).split(\",\")) {\n      let v = true;\n      e = e.trim();\n      if (e.startsWith(\"-\")) {\n        v = false;\n        e = e.slice(1).trim();\n      }\n      if (e) events[e] = v;\n    }\n  }\n\n  preset.events = events;\n\n  if (!preset.provision) throw new Error(\"Invalid preset provision\");\n\n  const configuration = {\n    type: \"provision\",\n    name: preset.provision,\n    args: null,\n  };\n\n  if (preset.provisionArgs)\n    configuration.args = parseList(preset.provisionArgs as string);\n\n  delete preset.provision;\n  delete preset.provisionArgs;\n  preset.configurations = [configuration];\n  return preset as unknown as MongoTypes.Preset;\n}\n\ninterface QueryOptions {\n  projection?: any;\n  skip?: number;\n  limit?: number;\n  sort?: {\n    [param: string]: number;\n  };\n}\n\nexport async function* query(\n  resource: string,\n  filter: Expression,\n  options?: QueryOptions,\n): AsyncGenerator<any, void, undefined> {\n  options = options || {};\n  const now = Date.now();\n  filter = filter.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"NOW\") return new Expression.Literal(now);\n    }\n    return e;\n  });\n  const q = toMongoQuery(filter, resource);\n  if (!q) return;\n\n  const collection = collections[resource] as Collection<any>;\n  const cursor = collection.find(q);\n  if (options.projection) {\n    let projection = options.projection;\n    if (resource === \"devices\")\n      projection = processDeviceProjection(options.projection);\n\n    if (resource === \"presets\") projection.configurations = 1;\n    projection = optimizeProjection(projection);\n    cursor.project(projection);\n  }\n\n  if (resource === \"users\") cursor.project({ password: 0, salt: 0 });\n\n  if (options.skip) cursor.skip(options.skip);\n  if (options.limit) cursor.limit(options.limit);\n\n  if (options.sort) {\n    let s = Object.entries(options.sort)\n      .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))\n      .reduce(\n        (obj, [k, v]) =>\n          Object.assign(obj, { [k]: Math.min(Math.max(v, -1), 1) }),\n        {},\n      );\n\n    if (resource === \"devices\") s = processDeviceSort(s);\n    cursor.sort(s);\n  }\n\n  for await (let doc of cursor) {\n    if (resource === \"devices\") doc = flattenDevice(doc);\n    else if (resource === \"faults\") doc = flattenFault(doc);\n    else if (resource === \"tasks\") doc = flattenTask(doc);\n    else if (resource === \"presets\") doc = flattenPreset(doc);\n    else if (resource === \"files\") doc = flattenFile(doc);\n\n    yield doc;\n  }\n}\n\nexport function count(resource: string, filter: Expression): Promise<number> {\n  const collection = collections[resource] as Collection<any>;\n  const now = Date.now();\n  filter = filter.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"NOW\") return new Expression.Literal(now);\n    }\n    return e;\n  });\n  const q = toMongoQuery(filter, resource);\n  if (!q) return Promise.resolve(0);\n  return collection.countDocuments(q);\n}\n\nexport async function updateDeviceTags(\n  deviceId: string,\n  tags: Record<string, boolean>,\n): Promise<void> {\n  const add = [];\n  const pull = [];\n\n  for (let [tag, onOff] of Object.entries(tags)) {\n    tag = tag.trim();\n    if (onOff) add.push(tag);\n    else pull.push(tag);\n  }\n  const object = {};\n\n  if (add?.length) object[\"$addToSet\"] = { _tags: { $each: add } };\n  if (pull?.length) object[\"$pullAll\"] = { _tags: pull };\n\n  await collections.devices.updateOne({ _id: deviceId }, object);\n}\n\nexport async function putPreset(\n  id: string,\n  object: Record<string, unknown>,\n): Promise<void> {\n  const p = preProcessPreset(object);\n  await collections.presets.replaceOne({ _id: id }, p, { upsert: true });\n}\n\nexport async function deletePreset(id: string): Promise<void> {\n  await collections.presets.deleteOne({ _id: id });\n}\n\nexport async function putProvision(\n  id: string,\n  object: { script: string },\n): Promise<void> {\n  if (!object.script) object.script = \"\";\n  try {\n    new Script(`\"use strict\";(function(){\\n${object.script}\\n})();`, {\n      filename: id,\n      lineOffset: -1,\n    });\n  } catch (err) {\n    if (err.stack?.startsWith(`${id}:`)) {\n      return Promise.reject(\n        new Error(`${err.name} at ${err.stack.split(\"\\n\", 1)[0]}`),\n      );\n    }\n    return Promise.reject(err);\n  }\n  await collections.provisions.replaceOne({ _id: id }, object, {\n    upsert: true,\n  });\n}\n\nexport async function deleteProvision(id: string): Promise<void> {\n  await collections.provisions.deleteOne({ _id: id });\n}\n\nexport async function putVirtualParameter(\n  id: string,\n  object: { script: string },\n): Promise<void> {\n  if (!object.script) object.script = \"\";\n  try {\n    new Script(`\"use strict\";(function(){\\n${object.script}\\n})();`, {\n      filename: id,\n      lineOffset: -1,\n    });\n  } catch (err) {\n    if (err.stack?.startsWith(`${id}:`)) {\n      return Promise.reject(\n        new Error(`${err.name} at ${err.stack.split(\"\\n\", 1)[0]}`),\n      );\n    }\n    return Promise.reject(err);\n  }\n  await collections.virtualParameters.replaceOne({ _id: id }, object, {\n    upsert: true,\n  });\n}\n\nexport async function deleteVirtualParameter(id: string): Promise<void> {\n  await collections.virtualParameters.deleteOne({ _id: id });\n}\n\nexport async function putConfig(\n  id: string,\n  object: WithoutId<MongoTypes.Config>,\n): Promise<void> {\n  await collections.config.replaceOne({ _id: id }, object, { upsert: true });\n}\n\nexport async function deleteConfig(id: string): Promise<void> {\n  await collections.config.deleteOne({ _id: id });\n}\n\nexport async function putPermission(\n  id: string,\n  object: WithoutId<MongoTypes.Permission>,\n): Promise<void> {\n  await collections.permissions.replaceOne({ _id: id }, object, {\n    upsert: true,\n  });\n}\n\nexport async function deletePermission(id: string): Promise<void> {\n  await collections.permissions.deleteOne({ _id: id });\n}\n\nexport async function putUser(\n  id: string,\n  object: Partial<WithoutId<MongoTypes.User>>,\n): Promise<void> {\n  // update instead of replace to keep the password if not set by user\n  await collections.users.updateOne(\n    { _id: id },\n    { $set: object },\n    { upsert: true },\n  );\n}\n\nexport async function deleteUser(id: string): Promise<void> {\n  await collections.users.deleteOne({ _id: id });\n}\n\nexport function downloadFile(filename: string): Readable {\n  return filesBucket.openDownloadStreamByName(filename);\n}\n\nexport function putFile(\n  filename: string,\n  metadata: Record<string, string>,\n  contentStream: Readable,\n): Promise<void> {\n  return new Promise((resolve, reject) => {\n    const uploadStream = filesBucket.openUploadStreamWithId(\n      filename as unknown as ObjectId,\n      filename,\n      {\n        metadata: metadata,\n      },\n    );\n\n    let readableEnded = false;\n    contentStream.on(\"end\", () => {\n      readableEnded = true;\n    });\n    contentStream.on(\"close\", () => {\n      // In Node versions prior to 15, the stream will not emit an error if the\n      // connection is closed before the stream is finished.\n      // For Node 12.9+ we can just use stream.readableEnded\n      if (!readableEnded)\n        uploadStream.destroy(new Error(\"Stream closed prematurely\"));\n    });\n\n    contentStream.on(\"error\", (err) => {\n      uploadStream.destroy(err);\n    });\n\n    uploadStream.on(\"error\", reject);\n    uploadStream.on(\"finish\", resolve);\n    contentStream.pipe(uploadStream);\n  });\n}\n\nexport async function deleteFile(filename: string): Promise<void> {\n  await filesBucket.delete(filename as any);\n}\n\nexport async function deleteFault(id: string): Promise<void> {\n  await collections.faults.deleteOne({ _id: id });\n}\n\nexport async function deleteTask(id: ObjectId): Promise<void> {\n  await collections.tasks.deleteOne({ _id: id });\n}\n\nexport async function putView(\n  id: string,\n  object: { script: string },\n): Promise<void> {\n  if (!object.script) object.script = \"\";\n  const err = await validateViewScript(id, object.script);\n  if (err) throw new Error(err);\n  await collections.views.replaceOne({ _id: id }, object, {\n    upsert: true,\n  });\n}\n\nexport async function deleteView(id: string): Promise<void> {\n  await collections.views.deleteOne({ _id: id });\n}\n"
  },
  {
    "path": "lib/ui/local-cache.ts",
    "content": "import * as crypto from \"node:crypto\";\nimport { collections } from \"../db/db.ts\";\nimport Expression, { Value } from \"../common/expression.ts\";\nimport { Users, Permissions, Config, UiConfig, Views } from \"../types.ts\";\nimport { LocalCache } from \"../local-cache.ts\";\nimport { bundleViews } from \"../bundle-views.ts\";\n\ninterface Snapshot {\n  permissions: Permissions;\n  users: Users;\n  config: Config;\n  ui: UiConfig;\n  viewsBundle: string;\n}\n\nasync function fetchPermissions(): Promise<[string, Permissions]> {\n  const perms = await collections.permissions.find().toArray();\n  perms.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto\n    .createHash(\"md5\")\n    .update(JSON.stringify(perms))\n    .digest(\"hex\");\n  const permissions: Permissions = {};\n\n  for (const p of perms) {\n    if (!permissions[p.role]) permissions[p.role] = {};\n    if (!permissions[p.role][p.access]) permissions[p.role][p.access] = {};\n\n    let validate: Expression;\n    if (p.validate) validate = Expression.parse(p.validate);\n    else validate = new Expression.Literal(true);\n    permissions[p.role][p.access][p.resource] = {\n      access: p.access,\n      filter: Expression.parse(p.filter || \"true\"),\n      validate,\n    };\n  }\n\n  return [h, permissions];\n}\n\nasync function fetchUsers(): Promise<[string, Users]> {\n  const _users = await collections.users.find().toArray();\n  _users.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto\n    .createHash(\"md5\")\n    .update(JSON.stringify(_users))\n    .digest(\"hex\");\n  const users = {};\n\n  for (const user of _users) {\n    users[user._id] = {\n      password: user.password,\n      salt: user.salt,\n      roles: user.roles.split(\",\").map((s) => s.trim()),\n    };\n  }\n\n  return [h, users];\n}\n\nasync function fetchConfig(): Promise<[string, Config, UiConfig]> {\n  const conf = await collections.config.find().toArray();\n  conf.sort((a, b) => (a._id > b._id ? 1 : -1));\n  const h = crypto.createHash(\"md5\").update(JSON.stringify(conf)).digest(\"hex\");\n\n  const ui: UiConfig = {};\n\n  const _config = {};\n\n  for (const c of conf) {\n    if (c._id.startsWith(\"ui.\")) {\n      ui[c._id.slice(3)] = c.value;\n      continue;\n    }\n    // Evaluate expressions to simplify them\n    const val = Expression.parse(c.value).evaluate((e) => e);\n    _config[c._id] = val;\n  }\n\n  return [h, _config, ui];\n}\n\nasync function fetchViews(): Promise<[string, Views]> {\n  const res = await collections.views.find().toArray();\n  const h = crypto.createHash(\"md5\").update(JSON.stringify(res)).digest(\"hex\");\n  const views: Views = {};\n  for (const r of res) {\n    views[r[\"_id\"]] = {\n      md5: crypto.createHash(\"md5\").update(r[\"script\"]).digest(\"hex\"),\n      script: r.script,\n    };\n  }\n  return [h, views];\n}\n\nconst localCache = new LocalCache<Snapshot>(\"ui-local-cache-hash\", refresh);\n\nasync function refresh(): Promise<[string, Snapshot]> {\n  const res = await Promise.all([\n    fetchPermissions(),\n    fetchUsers(),\n    fetchConfig(),\n    fetchViews(),\n  ]);\n\n  const h = crypto.createHash(\"md5\");\n  for (const r of res) h.update(r[0]);\n\n  const snapshot = {\n    permissions: res[0][1],\n    users: res[1][1],\n    config: res[2][1],\n    ui: res[2][2],\n    viewsBundle: await bundleViews(res[3][1]),\n  };\n\n  return [h.digest(\"hex\"), snapshot];\n}\n\nexport async function getRevision(): Promise<string> {\n  return await localCache.getRevision();\n}\n\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: string,\n  fn: (e: Expression) => Expression.Literal,\n): string;\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: number,\n  fn: (e: Expression) => Expression.Literal,\n): number;\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: boolean,\n  fn: (e: Expression) => Expression.Literal,\n): boolean;\nexport function getConfig(\n  revision: string,\n  key: string,\n  dflt: Value,\n  fn: (e: Expression) => Expression.Literal,\n): Value {\n  const snapshot = localCache.get(revision);\n  if (!snapshot) throw new Error(\"Cache snapshot does not exist\");\n\n  const e = snapshot.config[key];\n  if (!e) return dflt;\n  const v = e.evaluate(fn).value;\n  if (typeof v !== typeof dflt) return dflt;\n  return v;\n}\n\nexport function getConfigExpression(revision: string, key: string): Expression {\n  const snapshot = localCache.get(revision);\n  return snapshot.config[key];\n}\n\nexport function getUsers(revision: string): Users {\n  const snapshot = localCache.get(revision);\n  return snapshot.users;\n}\n\nexport function getPermissions(revision: string): Permissions {\n  const snapshot = localCache.get(revision);\n  return snapshot.permissions;\n}\n\nexport function getUiConfig(revision: string): UiConfig {\n  const snapshot = localCache.get(revision);\n  return snapshot.ui;\n}\n\nexport function getViewsBundle(revision: string): string {\n  const snapshot = localCache.get(revision);\n  return snapshot.viewsBundle;\n}\n"
  },
  {
    "path": "lib/ui.ts",
    "content": "import { constants } from \"node:zlib\";\nimport Koa from \"koa\";\nimport Router from \"@koa/router\";\nimport * as jwt from \"jsonwebtoken\";\nimport koaSend from \"koa-send\";\nimport koaCompress from \"koa-compress\";\nimport koaBodyParser from \"@koa/bodyparser\";\nimport koaJwt from \"koa-jwt\";\nimport * as config from \"./config.ts\";\nimport api from \"./ui/api.ts\";\nimport Authorizer from \"./common/authorizer.ts\";\nimport * as logger from \"./logger.ts\";\nimport * as localCache from \"./ui/local-cache.ts\";\nimport { PermissionSet } from \"./types.ts\";\nimport { authLocal } from \"./api-functions.ts\";\nimport * as init from \"./init.ts\";\nimport { version as VERSION } from \"../package.json\";\nimport memoize from \"./common/memoize.ts\";\nimport { APP_JS, APP_CSS, FAVICON_PNG } from \"../build/assets.ts\";\n\nconst koa = new Koa();\nconst router = new Router();\n\nconst JWT_SECRET = \"\" + config.get(\"UI_JWT_SECRET\");\nconst JWT_COOKIE = \"genieacs-ui-jwt\";\n\nconst getAuthorizer = memoize(\n  (snapshot: string, rolesStr: string): Authorizer => {\n    const roles: string[] = JSON.parse(rolesStr);\n    const allPermissions = localCache.getPermissions(snapshot);\n    const permissionSets: PermissionSet[] = roles.map((r) =>\n      Object.values(allPermissions[r] || {}),\n    );\n    return new Authorizer(permissionSets);\n  },\n);\n\nkoa.on(\"error\", (err, ctx) => {\n  setTimeout(() => {\n    // Ignored errors resulting from aborted requests\n    if (ctx?.req.aborted) return;\n\n    // Ignore client errors (e.g. malicious path)\n    if (err.status === 400) return;\n\n    throw err;\n  });\n});\n\nkoa.use(async (ctx, next) => {\n  const configSnapshot = await localCache.getRevision();\n  ctx.state.configSnapshot = configSnapshot;\n  ctx.set(\"X-Config-Snapshot\", configSnapshot);\n  ctx.set(\"GenieACS-Version\", VERSION);\n  return next();\n});\n\nkoa.use(\n  koaJwt({\n    secret: JWT_SECRET,\n    passthrough: true,\n    cookie: JWT_COOKIE,\n    isRevoked: async (ctx, token) => {\n      if (token[\"authMethod\"] === \"local\") {\n        return !localCache.getUsers(ctx.state.configSnapshot)[\n          token[\"username\"]\n        ];\n      }\n\n      return true;\n    },\n  }),\n);\n\nkoa.use(async (ctx, next) => {\n  let roles: string[] = [];\n\n  if (ctx.state.user?.username) {\n    let user;\n    if (ctx.state.user.authMethod === \"local\") {\n      user = localCache.getUsers(ctx.state.configSnapshot)[\n        ctx.state.user.username\n      ];\n    } else {\n      throw new Error(\"Invalid auth method\");\n    }\n    roles = user.roles || [];\n  }\n\n  ctx.state.authorizer = getAuthorizer(\n    ctx.state.configSnapshot,\n    JSON.stringify(roles),\n  );\n\n  return next();\n});\n\nrouter.post(\"/login\", async (ctx) => {\n  if (!JWT_SECRET) {\n    ctx.status = 500;\n    ctx.body = \"UI_JWT_SECRET is not set\";\n    logger.error({ message: \"UI_JWT_SECRET is not set\" });\n    return;\n  }\n\n  const username = ctx.request.body.username;\n  const password = ctx.request.body.password;\n  const remember = ctx.request.body.remember;\n  const TWO_WEEKS_SECS = 1209600;\n  const ONE_DAY_SECS = 86400;\n\n  const log = {\n    message: \"Log in\",\n    context: ctx,\n    username: username,\n    method: null,\n  };\n\n  function success(authMethod): void {\n    log.method = authMethod;\n    const expiresIn = remember ? TWO_WEEKS_SECS : ONE_DAY_SECS;\n    const token = jwt.sign({ username, authMethod }, JWT_SECRET, {\n      expiresIn,\n    });\n    ctx.cookies.set(JWT_COOKIE, token, {\n      sameSite: \"lax\",\n      maxAge: remember ? expiresIn * 1000 : null,\n    });\n    ctx.body = JSON.stringify(token);\n    logger.accessInfo(log);\n  }\n\n  function failure(): void {\n    ctx.status = 400;\n    ctx.body = \"Incorrect username or password\";\n    log.message += \" failed\";\n    logger.accessWarn(log);\n  }\n\n  if (await authLocal(ctx.state.configSnapshot, username, password))\n    return void success(\"local\");\n\n  failure();\n});\n\nrouter.post(\"/logout\", async (ctx) => {\n  ctx.cookies.set(JWT_COOKIE); // Delete cookie\n  ctx.body = \"\";\n\n  logger.accessInfo({\n    message: \"Log out\",\n    context: ctx,\n  });\n});\n\nkoa.use(async (ctx, next) => {\n  if (ctx.request.type === \"application/octet-stream\")\n    ctx.disableBodyParser = true;\n\n  return next();\n});\n\nkoa.use(koaBodyParser());\nrouter.use(\"/api\", api.routes(), api.allowedMethods());\n\nrouter.get(\"/health\", (ctx) => {\n  ctx.body = {\n    status: \"OK\",\n    timestamp: Date.now(),\n    configSnapshot: ctx.state.configSnapshot,\n    version: VERSION,\n  };\n});\n\nrouter.get(\"/init\", async (ctx) => {\n  const status = await init.getStatus();\n  if (Object.keys(localCache.getUsers(ctx.state.configSnapshot)).length) {\n    if (!ctx.state.authorizer.hasAccess(\"users\", 3)) status[\"users\"] = false;\n    if (!ctx.state.authorizer.hasAccess(\"permissions\", 3))\n      status[\"users\"] = false;\n    if (!ctx.state.authorizer.hasAccess(\"config\", 3)) {\n      status[\"filters\"] = false;\n      status[\"device\"] = false;\n      status[\"index\"] = false;\n      status[\"overview\"] = false;\n    }\n    if (!ctx.state.authorizer.hasAccess(\"presets\", 3))\n      status[\"presets\"] = false;\n    if (!ctx.state.authorizer.hasAccess(\"provisions\", 3))\n      status[\"presets\"] = false;\n  }\n\n  ctx.body = status;\n});\n\nrouter.post(\"/init\", async (ctx) => {\n  const status = ctx.request.body;\n  if (Object.keys(localCache.getUsers(ctx.state.configSnapshot)).length) {\n    if (!ctx.state.authorizer.hasAccess(\"users\", 3)) status[\"users\"] = false;\n    if (!ctx.state.authorizer.hasAccess(\"permissions\", 3))\n      status[\"users\"] = false;\n    if (!ctx.state.authorizer.hasAccess(\"config\", 3)) {\n      status[\"filters\"] = false;\n      status[\"device\"] = false;\n      status[\"index\"] = false;\n      status[\"overview\"] = false;\n    }\n    if (!ctx.state.authorizer.hasAccess(\"presets\", 3))\n      status[\"presets\"] = false;\n    if (!ctx.state.authorizer.hasAccess(\"provisions\", 3))\n      status[\"presets\"] = false;\n  }\n  await init.seed(status);\n  ctx.body = \"\";\n});\n\nrouter.get(\"/\", async (ctx) => {\n  // koa-router seems to tolerate double slashes in the URL but that can\n  // be problematic when using relatives asset paths in HTML\n  if (ctx.path.endsWith(\"//\")) return;\n\n  const ps: PermissionSet[] = ctx.state.authorizer.getPermissionSets();\n  const permissionSets = ps.map((p) =>\n    p.map((s) =>\n      Object.fromEntries(\n        Object.entries(s).map(([resource, { access, validate, filter }]) => [\n          resource,\n          { access, validate: validate.toString(), filter: filter.toString() },\n        ]),\n      ),\n    ),\n  );\n\n  let wizard = \"\";\n  if (!Object.keys(localCache.getUsers(ctx.state.configSnapshot)).length)\n    wizard = '<script>window.location.hash = \"#!/wizard\";</script>';\n\n  let viewsUrl: string;\n  if (ctx.state.user)\n    viewsUrl = `./views-bundle-${ctx.state.configSnapshot}.js`;\n  else viewsUrl = \"data:application/javascript,export default {}\";\n\n  ctx.body = `<!DOCTYPE html>\n  <html>\n    <head>\n      <title>GenieACS</title>\n      <link rel=\"shortcut icon\" type=\"image/png\" href=\"./${FAVICON_PNG}\" />\n      <link rel=\"stylesheet\" href=\"./${APP_CSS}\">\n    </head>\n    <body class=\"h-full bg-stone-100\">\n      <noscript>GenieACS UI requires JavaScript to work. Please enable JavaScript in your browser.</noscript>\n      <script type=\"importmap\">\n        {\n          \"imports\": {\n            \"views-bundle\": \"${viewsUrl}\"\n          }\n        }\n      </script>\n      <script>\n        window.clockSkew = ${Date.now()} - Date.now();\n        if (Math.abs(window.clockSkew) > 5000)\n          console.warn(\"System and server clocks are out of sync by \" + window.clockSkew + \"ms\");\n        window.clientConfig = ${JSON.stringify(localCache.getUiConfig(ctx.state.configSnapshot))};\n        window.configSnapshot = ${JSON.stringify(ctx.state.configSnapshot)};\n        window.genieacsVersion = ${JSON.stringify(VERSION)};\n        window.username = ${JSON.stringify(\n          ctx.state.user ? ctx.state.user.username : \"\",\n        )};\n        window.permissionSets = ${JSON.stringify(permissionSets)};\n      </script>\n      <script type=\"module\" src=\"./${APP_JS}\"></script>\n      ${wizard}\n    </body>\n  </html>\n  `;\n});\n\nrouter.get(\"/views-bundle-:revision.js\", async (ctx) => {\n  if (!ctx.state.user) return void (ctx.status = 403);\n  try {\n    ctx.body = localCache.getViewsBundle(ctx.params.revision);\n    ctx.set({ \"Content-Type\": \"application/javascript\" });\n  } catch {\n    ctx.status = 404;\n  }\n});\n\nkoa.use(\n  koaCompress({\n    gzip: {\n      flush: constants.Z_SYNC_FLUSH,\n    },\n    deflate: {\n      flush: constants.Z_SYNC_FLUSH,\n    },\n    br: {\n      flush: constants.BROTLI_OPERATION_FLUSH,\n      params: {\n        [constants.BROTLI_PARAM_QUALITY]: 5,\n      },\n    },\n  }),\n);\n\nkoa.use(router.routes());\nkoa.use(async (ctx, next) => {\n  await next();\n  if (ctx.method !== \"HEAD\" && ctx.method !== \"GET\") return;\n  if (ctx.body != null || ctx.status !== 404) return;\n  if (/(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)/.test(ctx.path)) return;\n\n  try {\n    await koaSend(ctx, ctx.path, { root: config.ROOT_DIR + \"/public\" });\n  } catch (err) {\n    if (err.status !== 404) throw err;\n  }\n});\n\nexport const listener = koa.callback();\n"
  },
  {
    "path": "lib/util.ts",
    "content": "import { EventEmitter } from \"node:events\";\n\nexport function generateDeviceId(\n  deviceIdStruct: Record<string, string>,\n): string {\n  // Escapes everything except alphanumerics and underscore\n  function esc(str): string {\n    return str.replace(/[^A-Za-z0-9_]/g, (chr) => {\n      const buf = Buffer.from(chr, \"utf8\");\n      let rep = \"\";\n      for (const b of buf) rep += \"%\" + b.toString(16).toUpperCase();\n      return rep;\n    });\n  }\n\n  // Guaranteeing globally unique id as defined in TR-069\n  if (deviceIdStruct[\"ProductClass\"]) {\n    return (\n      esc(deviceIdStruct[\"OUI\"]) +\n      \"-\" +\n      esc(deviceIdStruct[\"ProductClass\"]) +\n      \"-\" +\n      esc(deviceIdStruct[\"SerialNumber\"])\n    );\n  }\n  return esc(deviceIdStruct[\"OUI\"]) + \"-\" + esc(deviceIdStruct[\"SerialNumber\"]);\n}\n\n// Source: http://stackoverflow.com/a/6969486\nexport function escapeRegExp(str: string): string {\n  return str.replace(/[-[\\]/{}()*+?.\\\\^$|]/g, \"\\\\$&\");\n}\n\nexport function encodeTag(tag: string): string {\n  return encodeURIComponent(tag)\n    .replace(\n      /[!~*'().]/g,\n      (c) => \"%\" + c.charCodeAt(0).toString(16).toUpperCase(),\n    )\n    .replace(/0x(?=[0-9A-Z]{2})/g, \"0%78\")\n    .replace(/%/g, \"0x\");\n}\n\nexport function decodeTag(tag: string): string {\n  return decodeURIComponent(tag.replace(/0x(?=[0-9A-Z]{2})/g, \"%\"));\n}\n\nexport function once(\n  emitter: EventEmitter,\n  event: string,\n  timeout: number,\n): Promise<unknown[]> {\n  return new Promise((resolve, reject) => {\n    const timer = setTimeout(() => {\n      reject(new Error(`Event ${event} timed out after ${timeout} ms`));\n    }, timeout);\n\n    emitter.once(event, (...args: unknown[]) => {\n      clearTimeout(timer);\n      resolve(args);\n    });\n  });\n}\n\nexport function setTimeoutPromise(delay: number, ref = true): Promise<void> {\n  return new Promise((resolve) => {\n    const timerId = setTimeout(resolve, delay);\n    if (!ref) timerId.unref();\n  });\n}\n"
  },
  {
    "path": "lib/versioned-map.ts",
    "content": "const NONEXISTENT = Symbol();\nconst UNDEFINED = undefined;\n\ninterface Revisions<V> {\n  [rev: number]: V;\n  delete?: number;\n}\n\nexport default class VersionedMap<K, V> {\n  declare private _sizeDiff: number[];\n  declare private _revision: number;\n  declare private map: Map<K, (V | symbol)[]>;\n  declare public dirty: number;\n\n  public constructor() {\n    this._sizeDiff = [0];\n    this._revision = 0;\n    this.map = new Map();\n    this.dirty = 0;\n  }\n\n  public get size(): number {\n    return this.map.size + this._sizeDiff[this.revision];\n  }\n\n  public get revision(): number {\n    return this._revision;\n  }\n\n  public set revision(rev: number) {\n    for (let i = this._sizeDiff.length; i <= rev; ++i)\n      this._sizeDiff[i] = this._sizeDiff[i - 1];\n\n    this._revision = rev;\n  }\n\n  public get(key: K, rev = this._revision): V {\n    const revisions = this.map.get(key);\n    if (!revisions) return UNDEFINED;\n\n    const v = revisions[Math.min(revisions.length - 1, rev)];\n    if (v === NONEXISTENT) return UNDEFINED;\n    return v as V;\n  }\n\n  public has(key: K, rev = this._revision): boolean {\n    const revisions = this.map.get(key);\n    if (!revisions) return false;\n\n    const v = revisions[Math.min(revisions.length - 1, rev)];\n    if (v === NONEXISTENT) return false;\n    return true;\n  }\n\n  public set(key: K, value: V, rev = this._revision): this {\n    let revisions = this.map.get(key);\n    if (!revisions) {\n      this.dirty |= 1 << rev;\n      for (let i = 0; i < rev; ++i) this._sizeDiff[i] -= 1;\n\n      revisions = [];\n      for (let i = 0; i < rev; ++i) revisions[i] = NONEXISTENT;\n      revisions[rev] = value;\n      this.map.set(key, revisions);\n      return this;\n    }\n\n    // Can't modify old revisions\n    if (rev < revisions.length - 1) return null;\n\n    const old = revisions[revisions.length - 1];\n\n    this.dirty |= 1 << rev;\n    if (old === NONEXISTENT) ++this._sizeDiff[rev];\n\n    for (let i = revisions.length; i < rev; ++i) revisions[i] = old;\n    revisions[rev] = value;\n\n    return this;\n  }\n\n  public delete(key: K, rev = this._revision): boolean {\n    const revisions = this.map.get(key);\n    if (!revisions) return false;\n\n    // Can't modify old revisions\n    if (rev < revisions.length - 1) return null;\n\n    const old = revisions[revisions.length - 1];\n    if (old === NONEXISTENT) return false;\n\n    this.dirty |= 1 << rev;\n    --this._sizeDiff[rev];\n\n    for (let i = revisions.length; i < rev; ++i) revisions[i] = old;\n    revisions[rev] = NONEXISTENT;\n\n    return true;\n  }\n\n  public getRevisions(key: K): Revisions<V> {\n    const revisions = this.map.get(key);\n    if (!revisions) return null;\n\n    const res: Revisions<V> = {};\n\n    let prev: V | symbol = NONEXISTENT;\n    for (const [i, v] of revisions.entries()) {\n      if (v === prev) continue;\n      if (v === NONEXISTENT) res.delete |= 1 << i;\n      else res[i] = v as V;\n      prev = v;\n    }\n\n    if (prev === NONEXISTENT && !res.delete) return null;\n\n    return res;\n  }\n\n  public setRevisions(key: K, revisionsObj: Revisions<V>): void {\n    const del = revisionsObj.delete || 0;\n    const mutations = Object.keys(revisionsObj).reduce(\n      (acc, cur) => (cur === \"delete\" ? acc : acc | (1 << +cur)),\n      del,\n    );\n\n    if (!mutations) return;\n\n    const revisions = [];\n\n    let prev: V | symbol = NONEXISTENT;\n    for (let i = 0; mutations >> i; ++i) {\n      let v = prev;\n      if (del & (1 << i)) v = NONEXISTENT;\n      else if (i in revisionsObj) v = revisionsObj[i];\n      if (v !== prev) this.dirty |= 1 << i;\n      revisions[i] = v;\n      prev = v;\n    }\n\n    this.map.set(key, revisions);\n  }\n\n  public getDiff(key: K): [V, V] {\n    const revisions = this.map.get(key);\n    if (!revisions) return [UNDEFINED, UNDEFINED];\n    let first = revisions[0];\n    if (first === NONEXISTENT) first = UNDEFINED;\n    let last = revisions[revisions.length - 1];\n    if (last === NONEXISTENT) last = UNDEFINED;\n    return [first as V, last as V];\n  }\n\n  public *diff(): IterableIterator<[K, V, V]> {\n    for (const [key, revisions] of this.map) {\n      if (revisions.length <= 1) continue;\n      let first = revisions[0];\n      let last = revisions[revisions.length - 1];\n      if (first === NONEXISTENT && last === NONEXISTENT) continue;\n      if (first === NONEXISTENT) first = UNDEFINED;\n      if (last === NONEXISTENT) last = UNDEFINED;\n      yield [key, first as V, last as V];\n    }\n  }\n\n  public collapse(revision: number): void {\n    if (this._sizeDiff.length <= revision) return;\n\n    this._sizeDiff[revision] = this._sizeDiff[this._sizeDiff.length - 1];\n    this._sizeDiff.splice(revision + 1, this._sizeDiff.length);\n\n    const d = this.dirty >> revision;\n    this.dirty = this.dirty ^ (d << revision);\n    this.dirty |= +!!d << revision;\n\n    for (const [k, v] of this.map) {\n      const l = v.length - 1;\n      if (l <= revision) continue;\n      const last = v[l];\n      v.splice(revision, l - revision);\n\n      if (last === NONEXISTENT && !v.some((vv) => vv !== NONEXISTENT))\n        this.map.delete(k);\n    }\n  }\n\n  public *[Symbol.iterator](): IterableIterator<[K, V]> {\n    for (const [key, revisions] of this.map) {\n      const last = revisions[revisions.length - 1];\n      if (last === NONEXISTENT) continue;\n      yield [key, last as V];\n    }\n  }\n}\n"
  },
  {
    "path": "lib/xml-parser.ts",
    "content": "const CHAR_SINGLE_QUOTE = 39;\nconst CHAR_DOUBLE_QUOTE = 34;\nconst CHAR_LESS_THAN = 60;\nconst CHAR_GREATER_THAN = 62;\nconst CHAR_COLON = 58;\nconst CHAR_SPACE = 32;\nconst CHAR_TAB = 9;\nconst CHAR_CR = 13;\nconst CHAR_LF = 10;\nconst CHAR_SLASH = 47;\nconst CHAR_EXMARK = 33;\nconst CHAR_QMARK = 63;\nconst CHAR_EQUAL = 61;\n\nconst STATE_LESS_THAN = 1;\nconst STATE_SINGLE_QUOTE = 2;\nconst STATE_DOUBLE_QUOTE = 3;\n\nexport interface Attribute {\n  name: string;\n  namespace: string;\n  localName: string;\n  value: string;\n}\n\nexport interface Element {\n  name: string;\n  namespace: string;\n  localName: string;\n  attrs: string;\n  text: string;\n  bodyIndex: number;\n  children: Element[];\n}\n\nexport function parseXmlDeclaration(buffer: Buffer): Attribute[] {\n  const encodings: BufferEncoding[] = [\"utf16le\", \"utf8\", \"latin1\", \"ascii\"];\n  for (const enc of encodings) {\n    let str = buffer.toString(enc, 0, 150);\n    if (str.startsWith(\"<?xml\")) {\n      str = str.slice(0, str.indexOf(\"?>\"));\n      try {\n        return parseAttrs(str.slice(5));\n      } catch {\n        // Ignore\n      }\n    }\n  }\n  return null;\n}\n\nexport function parseAttrs(string: string): Attribute[] {\n  const attrs: Attribute[] = [];\n  const len = string.length;\n\n  let state = 0;\n  let name = \"\";\n  let namespace = \"\";\n  let localName = \"\";\n  let idx = 0;\n  let colonIdx = 0;\n  for (let i = 0; i < len; ++i) {\n    const c = string.charCodeAt(i);\n    switch (c) {\n      case CHAR_SINGLE_QUOTE:\n      case CHAR_DOUBLE_QUOTE:\n        if (state === c) {\n          state = 0;\n          if (name) {\n            const value = string.slice(idx + 1, i);\n            const e = {\n              name: name,\n              namespace: namespace,\n              localName: localName,\n              value: value,\n            };\n            attrs.push(e);\n            name = \"\";\n            idx = i + 1;\n          }\n        } else {\n          state = c;\n          idx = i;\n        }\n        continue;\n\n      case CHAR_COLON:\n        if (state) continue;\n        if (idx >= colonIdx) colonIdx = i;\n        continue;\n\n      case CHAR_EQUAL:\n        if (state) continue;\n        if (name) throw new Error(`Unexpected character at ${i}`);\n        name = string.slice(idx, i).trim();\n        // TODO validate name\n        if (colonIdx > idx) {\n          namespace = string.slice(idx, colonIdx).trim();\n          localName = string.slice(colonIdx + 1, i).trim();\n        } else {\n          namespace = \"\";\n          localName = name;\n        }\n    }\n  }\n\n  if (name) throw new Error(`Attribute must have value at ${idx}`);\n\n  const tail = string.slice(idx);\n  if (tail.trim()) throw new Error(`Unexpected string at ${len - tail.length}`);\n\n  return attrs;\n}\n\nexport function decodeEntities(string: string): string {\n  return string.replace(/&[0-9a-z#]+;/gi, (match) => {\n    switch (match) {\n      case \"&quot;\":\n        return '\"';\n\n      case \"&amp;\":\n        return \"&\";\n\n      case \"&apos;\":\n        return \"'\";\n\n      case \"&lt;\":\n        return \"<\";\n\n      case \"&gt;\":\n        return \">\";\n\n      default:\n        if (match.startsWith(\"&#x\")) {\n          const str = match.slice(3, -1).toLowerCase();\n          const n = parseInt(str, 16);\n          if (str.endsWith(n.toString(16))) return String.fromCharCode(n);\n        } else if (match.startsWith(\"&#\")) {\n          const str = match.slice(2, -1);\n          const n = parseInt(str);\n          if (str.endsWith(n.toString())) return String.fromCharCode(n);\n        }\n    }\n    return match;\n  });\n}\n\nexport function encodeEntities(string: string): string {\n  const entities = {\n    \"&\": \"&amp;\",\n    '\"': \"&quot;\",\n    \"'\": \"&apos;\",\n    \"<\": \"&lt;\",\n    \">\": \"&gt;\",\n  };\n  return string.replace(/[&\"'<>]/g, (m) => entities[m]);\n}\n\nexport function parseXml(string: string): Element {\n  const len = string.length;\n  let state1 = 0;\n  let state1Index = 0;\n  let state2 = 0;\n  let state2Index = 0;\n\n  const root: Element = {\n    name: \"root\",\n    namespace: \"\",\n    localName: \"root\",\n    attrs: \"\",\n    text: \"\",\n    bodyIndex: 0,\n    children: [],\n  };\n\n  const stack: Element[] = [root];\n\n  for (let i = 0; i < len; ++i) {\n    switch (string.charCodeAt(i)) {\n      case CHAR_SINGLE_QUOTE:\n        switch (state1 & 0xff) {\n          case STATE_SINGLE_QUOTE:\n            state1 = state2;\n            state1Index = state2Index;\n            state2 = 0;\n            continue;\n\n          case STATE_LESS_THAN:\n            state2 = state1;\n            state2Index = state1Index;\n            state1 = STATE_SINGLE_QUOTE;\n            state1Index = i;\n            continue;\n        }\n        continue;\n\n      case CHAR_DOUBLE_QUOTE:\n        switch (state1 & 0xff) {\n          case STATE_DOUBLE_QUOTE:\n            state1 = state2;\n            state1Index = state2Index;\n            state2 = 0;\n            continue;\n\n          case STATE_LESS_THAN:\n            state2 = state1;\n            state2Index = state1Index;\n            state1 = STATE_DOUBLE_QUOTE;\n            state1Index = i;\n            continue;\n        }\n        continue;\n\n      case CHAR_LESS_THAN:\n        if ((state1 & 0xff) === 0) {\n          state2 = state1;\n          state2Index = state1Index;\n          state1 = STATE_LESS_THAN;\n          state1Index = i;\n        }\n        continue;\n\n      case CHAR_COLON:\n        if ((state1 & 0xff) === STATE_LESS_THAN) {\n          const colonIndex = (state1 >> 8) & 0xff;\n          if (colonIndex === 0) state1 ^= ((i - state1Index) & 0xff) << 8;\n        }\n        continue;\n\n      case CHAR_SPACE:\n      case CHAR_TAB:\n      case CHAR_CR:\n      case CHAR_LF:\n        if ((state1 & 0xff) === STATE_LESS_THAN) {\n          const wsIndex = (state1 >> 16) & 0xff;\n          if (wsIndex === 0) state1 ^= ((i - state1Index) & 0xff) << 16;\n        }\n        continue;\n\n      case CHAR_GREATER_THAN:\n        if ((state1 & 0xff) === STATE_LESS_THAN) {\n          const secondChar = string.charCodeAt(state1Index + 1);\n          const wsIndex: number = (state1 >> 16) & 0xff;\n          let name: string,\n            colonIndex: number,\n            e: Element,\n            parent: Element,\n            selfClosing: number,\n            localName: string,\n            namespace: string;\n\n          switch (secondChar) {\n            case CHAR_SLASH:\n              e = stack.pop();\n              name =\n                wsIndex === 0\n                  ? string.slice(state1Index + 2, i)\n                  : string.slice(state1Index + 2, state1Index + wsIndex);\n              if (e.name !== name)\n                throw new Error(`Unmatched closing tag at ${i}`);\n              if (!e.children.length)\n                e.text = string.slice(e.bodyIndex, state1Index);\n              state1 = state2;\n              state1Index = state2Index;\n              state2 = 0;\n              continue;\n\n            case CHAR_EXMARK:\n              if (string.startsWith(\"![CDATA[\", state1Index + 1)) {\n                if (string.endsWith(\"]]\", i))\n                  throw new Error(`CDATA nodes are not supported at ${i}`);\n              } else if (string.startsWith(\"!--\", state1Index + 1)) {\n                // Comment node, ignore\n                if (string.endsWith(\"--\", i)) {\n                  state1 = state2;\n                  state1Index = state2Index;\n                  state2 = 0;\n                }\n              }\n              continue;\n\n            case CHAR_QMARK:\n              if (string.charCodeAt(i - 1) === CHAR_QMARK) {\n                // XML declaration node, ignore\n                state1 = state2;\n                state1Index = state2Index;\n                state2 = 0;\n              }\n              continue;\n\n            default:\n              selfClosing = +(string.charCodeAt(i - 1) === CHAR_SLASH);\n              parent = stack[stack.length - 1];\n              colonIndex = (state1 >> 8) & 0xff;\n\n              name =\n                wsIndex === 0\n                  ? string.slice(state1Index + 1, i - selfClosing)\n                  : string.slice(state1Index + 1, state1Index + wsIndex);\n              if (colonIndex && (!wsIndex || colonIndex < wsIndex)) {\n                localName = name.slice(colonIndex);\n                namespace = name.slice(0, colonIndex - 1);\n              } else {\n                localName = name;\n                namespace = \"\";\n              }\n\n              e = {\n                name: name,\n                namespace: namespace,\n                localName: localName,\n                attrs: wsIndex\n                  ? string.slice(state1Index + wsIndex + 1, i - selfClosing)\n                  : \"\",\n                text: \"\",\n                bodyIndex: i + 1,\n                children: [],\n              };\n              parent.children.push(e);\n              if (!selfClosing) stack.push(e);\n\n              state1 = state2;\n              state1Index = state2Index;\n              state2 = 0;\n              continue;\n          }\n        }\n        continue;\n    }\n  }\n\n  if (state1) throw new Error(`Unclosed token at ${state1Index}`);\n\n  if (stack.length > 1) {\n    const e = stack[stack.length - 1];\n    throw new Error(`Unclosed XML element at ${e.bodyIndex}`);\n  }\n\n  if (!root.children.length) root.text = string;\n  return root;\n}\n"
  },
  {
    "path": "lib/xmpp-client.ts",
    "content": "import * as net from \"node:net\";\nimport * as tls from \"node:tls\";\nimport { EventEmitter } from \"node:events\";\nimport { createHash, createHmac, randomBytes } from \"node:crypto\";\nimport { parseXml, Element, parseAttrs } from \"./xml-parser.ts\";\n\nfunction encodeBase64(str: string): string {\n  return Buffer.from(str).toString(\"base64\");\n}\n\nfunction decodeBase64(str: string): string {\n  return Buffer.from(str, \"base64\").toString();\n}\n\nfunction detectStreamTag(data: string): number {\n  const i1 = data.indexOf(\"<stream:stream\");\n  if (i1 < 0) throw new Error(\"Cannot detect opening stream tag\");\n  const i2 = data.indexOf(\">\", i1);\n  if (i2 < 0) throw new Error(\"Cannot detect opening stream tag\");\n  return i2 + 1;\n}\n\nfunction xmppStream<T>(\n  socket: net.Socket,\n  callback: Generator<void, T, Element>,\n): Promise<T> {\n  return new Promise((resolve, reject) => {\n    const onError = (err: Error): void => {\n      socket.removeListener(\"error\", onError);\n      reject(err);\n    };\n    socket.on(\"error\", onError);\n\n    const onData = (chunk: Buffer): void => {\n      try {\n        const str = chunk.toString(\"utf8\");\n        const xml = parseXml(str);\n        const { value, done } = callback.next(xml.children[0]);\n        if (done) {\n          socket.removeListener(\"error\", onError);\n          socket.removeListener(\"data\", onData);\n          resolve(value as T);\n        }\n      } catch (err) {\n        socket.removeListener(\"error\", onError);\n        socket.removeListener(\"data\", onData);\n        reject(err);\n      }\n    };\n\n    socket.once(\"data\", (chunk: Buffer) => {\n      try {\n        const str = chunk.toString(\"utf8\");\n        const i = detectStreamTag(str);\n        const streamTagStr = str.slice(0, i);\n        const xml = parseXml(streamTagStr + \"</stream:stream>\");\n        callback.next(xml.children[0]);\n        chunk = chunk.slice(Buffer.byteLength(streamTagStr));\n        if (chunk.length) onData(chunk);\n        socket.on(\"data\", onData);\n      } catch (err) {\n        socket.removeListener(\"error\", onError);\n        socket.removeListener(\"data\", onData);\n        reject(err);\n      }\n    });\n\n    try {\n      callback.next();\n    } catch (err) {\n      socket.removeListener(\"error\", onError);\n      socket.removeListener(\"data\", onData);\n      reject(err);\n    }\n  });\n}\n\nconst INT_1 = Buffer.from([0, 0, 0, 1]);\nconst saltedPasswordCache = {\n  password: \"\",\n  iterationCount: 0,\n  saltBase64: \"\",\n  salted: Buffer.allocUnsafe(0),\n};\n\nfunction saltPassword(\n  password: string,\n  saltBase64: string,\n  iteractionCount: number,\n): Buffer {\n  if (\n    password === saltedPasswordCache.password &&\n    saltBase64 === saltedPasswordCache.saltBase64 &&\n    iteractionCount === saltedPasswordCache.iterationCount\n  )\n    return saltedPasswordCache.salted;\n\n  const hi = createHmac(\"sha1\", password)\n    .update(Buffer.concat([Buffer.from(saltBase64, \"base64\"), INT_1]))\n    .digest();\n  let hi2: Buffer = hi;\n  for (let i = 1; i < iteractionCount; ++i) {\n    hi2 = createHmac(\"sha1\", password).update(hi2).digest();\n    for (const [j, b] of hi2.entries()) hi[j] ^= b;\n  }\n\n  saltedPasswordCache.saltBase64 = saltBase64;\n  saltedPasswordCache.password = password;\n  saltedPasswordCache.iterationCount = iteractionCount;\n  saltedPasswordCache.salted = hi;\n\n  return hi;\n}\n\nfunction* loginPlain(\n  socket: net.Socket,\n  username: string,\n  password: string,\n): Generator<void, void, Element> {\n  socket.write(\n    `<auth xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\" mechanism=\"PLAIN\">${encodeBase64(\n      `\\x00${username}\\x00${password}`,\n    )}</auth>`,\n  );\n  const res1 = yield;\n\n  if (\n    res1.name === \"failure\" &&\n    res1.children.some((c) => c.name === \"not-authorized\")\n  )\n    throw new Error(\"Not authorized\");\n\n  if (res1.name !== \"success\")\n    throw new Error(`Unexpected response ${res1.name}`);\n}\n\nfunction* loginScram(\n  socket: net.Socket,\n  username: string,\n  password: string,\n): Generator<void, void, Element> {\n  const cnonce = randomBytes(8).toString(\"base64\");\n  const gs2Header = \"n,,\";\n  const clientFirstMessageBare = `n=${username},r=${cnonce}`;\n  const clientFirstMessage = gs2Header + clientFirstMessageBare;\n  socket.write(\n    `<auth xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\" mechanism=\"SCRAM-SHA-1\">${encodeBase64(\n      clientFirstMessage,\n    )}</auth>`,\n  );\n  const res1 = yield;\n  if (res1.name !== \"challenge\")\n    throw new Error(`Unexpected element ${res1.name}`);\n  const serverFirstMessage = decodeBase64(res1.text);\n\n  let iterationCount: number;\n  let saltBase64: string;\n  let nonce: string;\n  for (const s of serverFirstMessage.split(\",\")) {\n    if (s.startsWith(\"i=\")) iterationCount = parseInt(s.slice(2));\n    else if (s.startsWith(\"s=\")) saltBase64 = s.slice(2);\n    else if (s.startsWith(\"r=\")) nonce = s.slice(2);\n  }\n  if (iterationCount == null || isNaN(iterationCount))\n    throw new Error(\"Invalid iteration count\");\n  if (saltBase64 == null) throw new Error(\"Missing salt\");\n  if (nonce == null) throw new Error(\"Missing nonce\");\n\n  const saltedPassword = saltPassword(password, saltBase64, iterationCount);\n  const clientKey = createHmac(\"sha1\", saltedPassword)\n    .update(\"Client Key\")\n    .digest();\n  const storedKey = createHash(\"sha1\").update(clientKey).digest();\n\n  const clientFinalMessageWithoutProof = `c=${encodeBase64(\n    gs2Header,\n  )},r=${nonce}`;\n  const authMessage = `${clientFirstMessageBare},${serverFirstMessage},${clientFinalMessageWithoutProof}`;\n  const clientSignature = createHmac(\"sha1\", storedKey)\n    .update(authMessage)\n    .digest();\n\n  const clientProof = Buffer.from(clientKey);\n  for (const [i, b] of clientSignature.entries()) clientProof[i] ^= b;\n\n  const clientFinalMessage = `${clientFinalMessageWithoutProof},p=${clientProof.toString(\n    \"base64\",\n  )}`;\n  socket.write(\n    `<response xmlns=\"urn:ietf:params:xml:ns:xmpp-sasl\">${encodeBase64(\n      clientFinalMessage,\n    )}</response>`,\n  );\n  const res2 = yield;\n\n  if (\n    res2.name === \"failure\" &&\n    res2.children.some((c) => c.name === \"not-authorized\")\n  )\n    throw new Error(\"Not authorized\");\n\n  if (res2.name !== \"success\")\n    throw new Error(`Unexpected response ${res2.name}`);\n\n  const serverKey = createHmac(\"sha1\", saltedPassword)\n    .update(\"Server Key\")\n    .digest();\n  const serverSignature = createHmac(\"sha1\", serverKey)\n    .update(authMessage)\n    .digest(\"base64\");\n\n  if (!decodeBase64(res2.text).endsWith(serverSignature))\n    throw new Error(\"Invalid server signature\");\n}\n\nfunction* starttls(socket: net.Socket): Generator<void, void, Element> {\n  socket.write(\"<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>\");\n  const res1 = yield;\n  if (res1.name !== \"proceed\") throw new Error(\"Failed to initiate STARTTLS\");\n}\n\nfunction* bind(\n  socket: net.Socket,\n  resource: string,\n): Generator<void, void, Element> {\n  const id = randomBytes(8).toString(\"base64\");\n  socket.write(\n    `<iq id='${id}' type='set'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>${resource}</resource></bind></iq>`,\n  );\n  const res1 = yield;\n  if (res1.name !== \"iq\") throw new Error(`Unexpected element ${res1.name}`);\n  const attrs1 = parseAttrs(res1.attrs);\n  const idAttr = attrs1.find((a) => a.name === \"id\");\n  if (!idAttr || idAttr.value !== id) throw new Error(\"Invalid ID\");\n  const typeAttr = attrs1.find((a) => a.name === \"type\");\n  if (!typeAttr) throw new Error(\"Missing type attribute\");\n  if (typeAttr.value !== \"result\") throw new Error(\"Cannot bind to resource\");\n}\n\nconst STATUS_RESTART_STREAM = 1;\nconst STATUS_STARTTLS = 2;\n\nfunction* init(\n  socket: net.Socket,\n  host: string,\n  username: string,\n  password: string,\n  resource: string,\n): Generator<void, number, Element> {\n  socket.write(\n    `<?xml version='1.0'?><stream:stream from='${username}@${host}' to='${host}' version='1.0' xml:lang='en' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'>`,\n  );\n  const open = yield;\n  if (open.name !== \"stream:stream\")\n    throw new Error(`Unexpected element ${open.name}`);\n  const features = yield;\n  if (features.name !== \"stream:features\")\n    throw new Error(`Unexpected element ${features.name}`);\n  for (const feature of features.children) {\n    if (feature.name === \"starttls\") {\n      if (feature.children.some((c) => c.name === \"required\")) {\n        yield* starttls(socket);\n        return STATUS_STARTTLS;\n      }\n    } else if (feature.name === \"mechanisms\") {\n      const mechanisms: Set<string> = new Set(\n        feature.children.map((c) => c.text),\n      );\n      if (mechanisms.has(\"PLAIN\")) {\n        yield* loginPlain(socket, username, password);\n        return STATUS_RESTART_STREAM;\n      } else if (mechanisms.has(\"SCRAM-SHA-1\")) {\n        yield* loginScram(socket, username, password);\n        return STATUS_RESTART_STREAM;\n      } else {\n        throw new Error(\"No supported SASL method\");\n      }\n    } else if (feature.name === \"bind\") {\n      yield* bind(socket, resource);\n      return 0;\n    }\n  }\n  return 0;\n}\n\nfunction upgradeTls(socket: net.Socket, host: string): Promise<tls.TLSSocket> {\n  return new Promise((resolve, reject) => {\n    socket.on(\"error\", reject);\n    const newSocket = tls.connect({ socket, host }, () => {\n      socket.removeListener(\"error\", reject);\n      resolve(newSocket);\n    });\n  });\n}\n\ninterface XmppClientOptions {\n  host: string;\n  port?: number;\n  username?: string;\n  password?: string;\n  resource?: string;\n  timeout?: number;\n}\n\nexport default class XmppClient extends EventEmitter {\n  private _socket: net.Socket;\n  private _host: string;\n  private _username: string;\n  private _resource: string;\n  private _iqStanzaCallbacks: Map<\n    string,\n    (err: Error, r?: { rawRes: string; res: Element }) => void\n  >;\n\n  private constructor() {\n    super();\n    this._socket = null;\n    this._host = null;\n    this._username = null;\n    this._resource = null;\n    this._iqStanzaCallbacks = new Map();\n  }\n\n  static async connect(opts: XmppClientOptions): Promise<XmppClient> {\n    function connectSocket(host: string, port: number): Promise<net.Socket> {\n      return new Promise((resolve, reject) => {\n        const socket = new net.Socket();\n        socket.on(\"error\", reject);\n        socket.connect(port, host, () => {\n          socket.removeListener(\"error\", reject);\n          resolve(socket);\n        });\n      });\n    }\n\n    let socket = await connectSocket(opts.host, opts.port || 5222);\n    try {\n      let status = 1;\n      while (status) {\n        if (status === STATUS_STARTTLS)\n          socket = await upgradeTls(socket, opts.host);\n        status = await xmppStream(\n          socket,\n          init(socket, opts.host, opts.username, opts.password, opts.resource),\n        );\n      }\n    } catch (err) {\n      socket.destroy();\n      throw err;\n    }\n\n    const client = new XmppClient();\n    client._socket = socket;\n    client._host = opts.host;\n    client._username = opts.username;\n    client._resource = opts.resource;\n    socket.on(\"data\", client._onData.bind(client));\n    socket.on(\"error\", client._onError.bind(client));\n    if (opts.timeout)\n      socket.setTimeout(opts.timeout, client.close.bind(client));\n    return client;\n  }\n\n  close(): void {\n    this._socket.end(\"</stream:stream>\");\n    this._socket.removeAllListeners(\"data\");\n    this._socket.removeAllListeners(\"error\");\n    this.emit(\"close\");\n  }\n\n  ref(): void {\n    this._socket.ref();\n  }\n\n  unref(): void {\n    this._socket.unref();\n  }\n\n  get host(): string {\n    return this._host;\n  }\n\n  get username(): string {\n    return this._username;\n  }\n\n  get resource(): string {\n    return this._resource;\n  }\n\n  private _onData(chunk: Buffer): void {\n    try {\n      let close = false;\n      let str = chunk.toString(\"utf8\");\n      if (str.endsWith(\"</stream:stream>\")) {\n        str = str.slice(0, -16);\n        close = true;\n      }\n      const xml = parseXml(str);\n      const idx = xml.children.map((c) => c.bodyIndex);\n      for (const [i, c] of xml.children.entries()) {\n        const s = str.slice(idx[i], idx[i + 1]);\n        if (c.name === \"iq\") {\n          const attrs = parseAttrs(c.attrs);\n          const id = attrs.find((a) => a.name === \"id\");\n          if (id) {\n            const cb = this._iqStanzaCallbacks.get(id.value);\n            if (cb) cb(null, { rawRes: s, res: c });\n          }\n        }\n        this.emit(\"stanza\", c, s);\n      }\n      if (close) {\n        this._socket.removeAllListeners(\"data\");\n        this._socket.removeAllListeners(\"error\");\n        this.emit(\"close\");\n      }\n    } catch (err) {\n      this._socket.removeAllListeners(\"data\");\n      this._socket.removeAllListeners(\"error\");\n      this.emit(\"error\", err);\n    }\n  }\n\n  private _onError(err: Error): void {\n    this._socket.end();\n    this.emit(\"error\", err);\n    for (const cb of this._iqStanzaCallbacks.values()) cb(err);\n  }\n\n  send(msg: string): void {\n    this._socket.write(msg);\n  }\n\n  sendIqStanza(\n    from: string,\n    to: string,\n    type: string,\n    body: string,\n    timeout = 3000,\n  ): Promise<{ rawReq: string; rawRes: string; res: Element }> {\n    return new Promise((resolve, reject) => {\n      const id = randomBytes(8).toString(\"base64\");\n      const rawReq = `<iq from=\"${from}\" to=\"${to}\" id=\"${id}\" type=\"${type}\">${body}</iq>`;\n      this.send(rawReq);\n      const t = setTimeout(() => {\n        this._iqStanzaCallbacks.delete(id);\n        reject(\n          new Error(\"Did not receive IQ stanza response in a timely manner\"),\n        );\n      }, timeout);\n      this._iqStanzaCallbacks.set(id, (err, r) => {\n        this._iqStanzaCallbacks.delete(id);\n        clearTimeout(t);\n        if (err) reject(err);\n        else resolve(Object.assign(r, { rawReq }));\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "npm-shrinkwrap.json",
    "content": "{\n  \"name\": \"genieacs\",\n  \"version\": \"1.3.0-dev\",\n  \"lockfileVersion\": 2,\n  \"requires\": true,\n  \"packages\": {\n    \"\": {\n      \"name\": \"genieacs\",\n      \"version\": \"1.3.0-dev\",\n      \"license\": \"AGPL-3.0\",\n      \"dependencies\": {\n        \"@breejs/later\": \"^4.2.0\",\n        \"@koa/bodyparser\": \"^5.1.2\",\n        \"@koa/router\": \"^13.1.1\",\n        \"bson\": \"^4.7.2\",\n        \"espresso-iisojs\": \"^1.0.8\",\n        \"iconv-lite\": \"^0.6.3\",\n        \"ipaddr.js\": \"^2.3.0\",\n        \"jsonwebtoken\": \"^9.0.3\",\n        \"koa\": \"^2.16.4\",\n        \"koa-compress\": \"^5.2.0\",\n        \"koa-jwt\": \"^4.0.3\",\n        \"koa-send\": \"^5.0.1\",\n        \"mongodb\": \"^4.16.0\",\n        \"seedrandom\": \"^3.0.5\"\n      },\n      \"bin\": {\n        \"genieacs-cwmp\": \"bin/genieacs-cwmp\",\n        \"genieacs-fs\": \"bin/genieacs-fs\",\n        \"genieacs-nbi\": \"bin/genieacs-nbi\",\n        \"genieacs-ui\": \"bin/genieacs-ui\"\n      },\n      \"devDependencies\": {\n        \"@codemirror/commands\": \"^6.10.2\",\n        \"@codemirror/lang-javascript\": \"^6.2.5\",\n        \"@codemirror/lang-yaml\": \"^6.1.2\",\n        \"@codemirror/language\": \"^6.12.2\",\n        \"@codemirror/state\": \"^6.5.4\",\n        \"@codemirror/view\": \"^6.39.16\",\n        \"@eslint/js\": \"^10.0.1\",\n        \"@tailwindcss/cli\": \"^4.2.1\",\n        \"@tailwindcss/forms\": \"^0.5.11\",\n        \"@types/jsonwebtoken\": \"^9.0.10\",\n        \"@types/koa\": \"^2.15.0\",\n        \"@types/koa-compress\": \"^4.0.7\",\n        \"@types/mithril\": \"^2.2.7\",\n        \"@types/node\": \"^25.3.5\",\n        \"@types/seedrandom\": \"^3.0.8\",\n        \"esbuild\": \"^0.27.4\",\n        \"eslint\": \"^10.0.3\",\n        \"eslint-config-prettier\": \"^10.1.8\",\n        \"globals\": \"^17.4.0\",\n        \"mithril\": \"^2.3.8\",\n        \"prettier\": \"^3.8.1\",\n        \"sql.js\": \"^1.14.1\",\n        \"svgo\": \"^3.3.3\",\n        \"tailwindcss\": \"^4.2.1\",\n        \"typescript\": \"^5.9.3\",\n        \"typescript-eslint\": \"^8.56.1\",\n        \"yaml\": \"^1.10.2\"\n      },\n      \"engines\": {\n        \"node\": \">=12.13.0\"\n      }\n    },\n    \"node_modules/@aws-crypto/sha256-browser\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz\",\n      \"integrity\": \"sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-crypto/sha256-js\": \"^5.2.0\",\n        \"@aws-crypto/supports-web-crypto\": \"^5.2.0\",\n        \"@aws-crypto/util\": \"^5.2.0\",\n        \"@aws-sdk/types\": \"^3.222.0\",\n        \"@aws-sdk/util-locate-window\": \"^3.0.0\",\n        \"@smithy/util-utf8\": \"^2.0.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz\",\n      \"integrity\": \"sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz\",\n      \"integrity\": \"sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/is-array-buffer\": \"^2.2.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz\",\n      \"integrity\": \"sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/util-buffer-from\": \"^2.2.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/@aws-crypto/sha256-js\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz\",\n      \"integrity\": \"sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-crypto/util\": \"^5.2.0\",\n        \"@aws-sdk/types\": \"^3.222.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=16.0.0\"\n      }\n    },\n    \"node_modules/@aws-crypto/supports-web-crypto\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz\",\n      \"integrity\": \"sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"node_modules/@aws-crypto/util\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz\",\n      \"integrity\": \"sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.222.0\",\n        \"@smithy/util-utf8\": \"^2.0.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz\",\n      \"integrity\": \"sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz\",\n      \"integrity\": \"sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/is-array-buffer\": \"^2.2.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz\",\n      \"integrity\": \"sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/util-buffer-from\": \"^2.2.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/client-cognito-identity\": {\n      \"version\": \"3.1008.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1008.0.tgz\",\n      \"integrity\": \"sha512-zzHnrTImR1JJ/Sq90y35UiFiriwge6W8qZQxIBJCgAMwEGkQAqHEAc3d6ptLmwdntcid3dx7wvauOXbpiMVbAQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-crypto/sha256-browser\": \"5.2.0\",\n        \"@aws-crypto/sha256-js\": \"5.2.0\",\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/credential-provider-node\": \"^3.972.20\",\n        \"@aws-sdk/middleware-host-header\": \"^3.972.7\",\n        \"@aws-sdk/middleware-logger\": \"^3.972.7\",\n        \"@aws-sdk/middleware-recursion-detection\": \"^3.972.7\",\n        \"@aws-sdk/middleware-user-agent\": \"^3.972.20\",\n        \"@aws-sdk/region-config-resolver\": \"^3.972.7\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/util-endpoints\": \"^3.996.4\",\n        \"@aws-sdk/util-user-agent-browser\": \"^3.972.7\",\n        \"@aws-sdk/util-user-agent-node\": \"^3.973.6\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/fetch-http-handler\": \"^5.3.13\",\n        \"@smithy/hash-node\": \"^4.2.11\",\n        \"@smithy/invalid-dependency\": \"^4.2.11\",\n        \"@smithy/middleware-content-length\": \"^4.2.11\",\n        \"@smithy/middleware-endpoint\": \"^4.4.23\",\n        \"@smithy/middleware-retry\": \"^4.4.40\",\n        \"@smithy/middleware-serde\": \"^4.2.12\",\n        \"@smithy/middleware-stack\": \"^4.2.11\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/node-http-handler\": \"^4.4.14\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/url-parser\": \"^4.2.11\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-body-length-browser\": \"^4.2.2\",\n        \"@smithy/util-body-length-node\": \"^4.2.3\",\n        \"@smithy/util-defaults-mode-browser\": \"^4.3.39\",\n        \"@smithy/util-defaults-mode-node\": \"^4.2.42\",\n        \"@smithy/util-endpoints\": \"^3.3.2\",\n        \"@smithy/util-middleware\": \"^4.2.11\",\n        \"@smithy/util-retry\": \"^4.2.11\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/core\": {\n      \"version\": \"3.973.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz\",\n      \"integrity\": \"sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/xml-builder\": \"^3.972.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/signature-v4\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-middleware\": \"^4.2.11\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-cognito-identity\": {\n      \"version\": \"3.972.12\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.12.tgz\",\n      \"integrity\": \"sha512-0R7EKJBd19VGoYMrp7ozikwRh6KpapIO3T/Vf9tMrAVxrUNd5V+A6V1gxypY7iJv9GwVR1ZWL/nFt/m0KvcjIQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-env\": {\n      \"version\": \"3.972.17\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz\",\n      \"integrity\": \"sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-http\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz\",\n      \"integrity\": \"sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/fetch-http-handler\": \"^5.3.13\",\n        \"@smithy/node-http-handler\": \"^4.4.14\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-stream\": \"^4.5.17\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-ini\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.19.tgz\",\n      \"integrity\": \"sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/credential-provider-env\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-http\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-login\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-process\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-sso\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-web-identity\": \"^3.972.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/credential-provider-imds\": \"^4.2.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-login\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.19.tgz\",\n      \"integrity\": \"sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-node\": {\n      \"version\": \"3.972.20\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.20.tgz\",\n      \"integrity\": \"sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/credential-provider-env\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-http\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-ini\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-process\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-sso\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-web-identity\": \"^3.972.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/credential-provider-imds\": \"^4.2.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-process\": {\n      \"version\": \"3.972.17\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz\",\n      \"integrity\": \"sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-sso\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.19.tgz\",\n      \"integrity\": \"sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/token-providers\": \"3.1008.0\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-provider-web-identity\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.19.tgz\",\n      \"integrity\": \"sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/credential-providers\": {\n      \"version\": \"3.1008.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1008.0.tgz\",\n      \"integrity\": \"sha512-JPjsKAYpuaDwmeE2WvrrfTb27FYa6kIe0gj1JCazHWGteQ6LDycBddsDsRSgq2MfqAqdcHnrgnfGzY1+j8AxoQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/client-cognito-identity\": \"3.1008.0\",\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/credential-provider-cognito-identity\": \"^3.972.12\",\n        \"@aws-sdk/credential-provider-env\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-http\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-ini\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-login\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-node\": \"^3.972.20\",\n        \"@aws-sdk/credential-provider-process\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-sso\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-web-identity\": \"^3.972.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/credential-provider-imds\": \"^4.2.11\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/middleware-host-header\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz\",\n      \"integrity\": \"sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/middleware-logger\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz\",\n      \"integrity\": \"sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/middleware-recursion-detection\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz\",\n      \"integrity\": \"sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws/lambda-invoke-store\": \"^0.2.2\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/middleware-user-agent\": {\n      \"version\": \"3.972.20\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz\",\n      \"integrity\": \"sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/util-endpoints\": \"^3.996.4\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-retry\": \"^4.2.11\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/nested-clients\": {\n      \"version\": \"3.996.9\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.9.tgz\",\n      \"integrity\": \"sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-crypto/sha256-browser\": \"5.2.0\",\n        \"@aws-crypto/sha256-js\": \"5.2.0\",\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/middleware-host-header\": \"^3.972.7\",\n        \"@aws-sdk/middleware-logger\": \"^3.972.7\",\n        \"@aws-sdk/middleware-recursion-detection\": \"^3.972.7\",\n        \"@aws-sdk/middleware-user-agent\": \"^3.972.20\",\n        \"@aws-sdk/region-config-resolver\": \"^3.972.7\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/util-endpoints\": \"^3.996.4\",\n        \"@aws-sdk/util-user-agent-browser\": \"^3.972.7\",\n        \"@aws-sdk/util-user-agent-node\": \"^3.973.6\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/fetch-http-handler\": \"^5.3.13\",\n        \"@smithy/hash-node\": \"^4.2.11\",\n        \"@smithy/invalid-dependency\": \"^4.2.11\",\n        \"@smithy/middleware-content-length\": \"^4.2.11\",\n        \"@smithy/middleware-endpoint\": \"^4.4.23\",\n        \"@smithy/middleware-retry\": \"^4.4.40\",\n        \"@smithy/middleware-serde\": \"^4.2.12\",\n        \"@smithy/middleware-stack\": \"^4.2.11\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/node-http-handler\": \"^4.4.14\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/url-parser\": \"^4.2.11\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-body-length-browser\": \"^4.2.2\",\n        \"@smithy/util-body-length-node\": \"^4.2.3\",\n        \"@smithy/util-defaults-mode-browser\": \"^4.3.39\",\n        \"@smithy/util-defaults-mode-node\": \"^4.2.42\",\n        \"@smithy/util-endpoints\": \"^3.3.2\",\n        \"@smithy/util-middleware\": \"^4.2.11\",\n        \"@smithy/util-retry\": \"^4.2.11\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/region-config-resolver\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz\",\n      \"integrity\": \"sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/token-providers\": {\n      \"version\": \"3.1008.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1008.0.tgz\",\n      \"integrity\": \"sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/types\": {\n      \"version\": \"3.973.5\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz\",\n      \"integrity\": \"sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/util-endpoints\": {\n      \"version\": \"3.996.4\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz\",\n      \"integrity\": \"sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/url-parser\": \"^4.2.11\",\n        \"@smithy/util-endpoints\": \"^3.3.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/util-locate-window\": {\n      \"version\": \"3.965.5\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz\",\n      \"integrity\": \"sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws-sdk/util-user-agent-browser\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz\",\n      \"integrity\": \"sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"bowser\": \"^2.11.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"node_modules/@aws-sdk/util-user-agent-node\": {\n      \"version\": \"3.973.6\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.6.tgz\",\n      \"integrity\": \"sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@aws-sdk/middleware-user-agent\": \"^3.972.20\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-config-provider\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      },\n      \"peerDependencies\": {\n        \"aws-crt\": \">=1.0.0\"\n      },\n      \"peerDependenciesMeta\": {\n        \"aws-crt\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/@aws-sdk/xml-builder\": {\n      \"version\": \"3.972.10\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz\",\n      \"integrity\": \"sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.0\",\n        \"fast-xml-parser\": \"5.4.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=20.0.0\"\n      }\n    },\n    \"node_modules/@aws/lambda-invoke-store\": {\n      \"version\": \"0.2.4\",\n      \"resolved\": \"https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz\",\n      \"integrity\": \"sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==\",\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@breejs/later\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@breejs/later/-/later-4.2.0.tgz\",\n      \"integrity\": \"sha512-EVMD0SgJtOuFeg0lAVbCwa+qeTKILb87jqvLyUtQswGD9+ce2nB52Y5zbTF1Hc0MDFfbydcMcxb47jSdhikVHA==\",\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/@codemirror/autocomplete\": {\n      \"version\": \"6.16.3\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.3.tgz\",\n      \"integrity\": \"sha512-Vl/tIeRVVUCRDuOG48lttBasNQu8usGgXQawBXI7WJAiUDSFOfzflmEsZFZo48mAvAaa4FZ/4/yLLxFtdJaKYA==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@codemirror/language\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.17.0\",\n        \"@lezer/common\": \"^1.0.0\"\n      },\n      \"peerDependencies\": {\n        \"@codemirror/language\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.0.0\",\n        \"@lezer/common\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/@codemirror/commands\": {\n      \"version\": \"6.10.2\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz\",\n      \"integrity\": \"sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@codemirror/language\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.4.0\",\n        \"@codemirror/view\": \"^6.27.0\",\n        \"@lezer/common\": \"^1.1.0\"\n      }\n    },\n    \"node_modules/@codemirror/lang-javascript\": {\n      \"version\": \"6.2.5\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz\",\n      \"integrity\": \"sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@codemirror/autocomplete\": \"^6.0.0\",\n        \"@codemirror/language\": \"^6.6.0\",\n        \"@codemirror/lint\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.17.0\",\n        \"@lezer/common\": \"^1.0.0\",\n        \"@lezer/javascript\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/@codemirror/lang-yaml\": {\n      \"version\": \"6.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz\",\n      \"integrity\": \"sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@codemirror/autocomplete\": \"^6.0.0\",\n        \"@codemirror/language\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@lezer/common\": \"^1.2.0\",\n        \"@lezer/highlight\": \"^1.2.0\",\n        \"@lezer/lr\": \"^1.0.0\",\n        \"@lezer/yaml\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/@codemirror/language\": {\n      \"version\": \"6.12.2\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz\",\n      \"integrity\": \"sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.23.0\",\n        \"@lezer/common\": \"^1.5.0\",\n        \"@lezer/highlight\": \"^1.0.0\",\n        \"@lezer/lr\": \"^1.0.0\",\n        \"style-mod\": \"^4.0.0\"\n      }\n    },\n    \"node_modules/@codemirror/lint\": {\n      \"version\": \"6.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz\",\n      \"integrity\": \"sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.0.0\",\n        \"crelt\": \"^1.0.5\"\n      }\n    },\n    \"node_modules/@codemirror/state\": {\n      \"version\": \"6.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz\",\n      \"integrity\": \"sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@marijn/find-cluster-break\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/@codemirror/view\": {\n      \"version\": \"6.39.16\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz\",\n      \"integrity\": \"sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@codemirror/state\": \"^6.5.0\",\n        \"crelt\": \"^1.0.6\",\n        \"style-mod\": \"^4.1.0\",\n        \"w3c-keyname\": \"^2.2.4\"\n      }\n    },\n    \"node_modules/@esbuild/aix-ppc64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz\",\n      \"integrity\": \"sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"aix\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-arm\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz\",\n      \"integrity\": \"sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/android-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/darwin-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/darwin-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/freebsd-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/freebsd-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-arm\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz\",\n      \"integrity\": \"sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-ia32\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz\",\n      \"integrity\": \"sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-loong64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz\",\n      \"integrity\": \"sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==\",\n      \"cpu\": [\n        \"loong64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-mips64el\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz\",\n      \"integrity\": \"sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==\",\n      \"cpu\": [\n        \"mips64el\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-ppc64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz\",\n      \"integrity\": \"sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==\",\n      \"cpu\": [\n        \"ppc64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-riscv64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz\",\n      \"integrity\": \"sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==\",\n      \"cpu\": [\n        \"riscv64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-s390x\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz\",\n      \"integrity\": \"sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==\",\n      \"cpu\": [\n        \"s390x\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/linux-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/netbsd-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"optional\": true,\n      \"os\": [\n        \"netbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/netbsd-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"netbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openbsd-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openbsd-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"netbsd\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/openharmony-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"openharmony\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/sunos-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"sunos\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-ia32\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz\",\n      \"integrity\": \"sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@esbuild/win32-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">=18\"\n      }\n    },\n    \"node_modules/@eslint-community/eslint-utils\": {\n      \"version\": \"4.9.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz\",\n      \"integrity\": \"sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"eslint-visitor-keys\": \"^3.4.3\"\n      },\n      \"engines\": {\n        \"node\": \"^12.22.0 || ^14.17.0 || >=16.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint\"\n      },\n      \"peerDependencies\": {\n        \"eslint\": \"^6.0.0 || ^7.0.0 || >=8.0.0\"\n      }\n    },\n    \"node_modules/@eslint-community/regexpp\": {\n      \"version\": \"4.12.2\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz\",\n      \"integrity\": \"sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \"^12.0.0 || ^14.0.0 || >=16.0.0\"\n      }\n    },\n    \"node_modules/@eslint/config-array\": {\n      \"version\": \"0.23.3\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz\",\n      \"integrity\": \"sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@eslint/object-schema\": \"^3.0.3\",\n        \"debug\": \"^4.3.1\",\n        \"minimatch\": \"^10.2.4\"\n      },\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      }\n    },\n    \"node_modules/@eslint/config-array/node_modules/balanced-match\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz\",\n      \"integrity\": \"sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      }\n    },\n    \"node_modules/@eslint/config-array/node_modules/brace-expansion\": {\n      \"version\": \"5.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz\",\n      \"integrity\": \"sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^4.0.2\"\n      },\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      }\n    },\n    \"node_modules/@eslint/config-array/node_modules/minimatch\": {\n      \"version\": \"10.2.4\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz\",\n      \"integrity\": \"sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==\",\n      \"dev\": true,\n      \"license\": \"BlueOak-1.0.0\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^5.0.2\"\n      },\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/@eslint/config-helpers\": {\n      \"version\": \"0.5.3\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz\",\n      \"integrity\": \"sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@eslint/core\": \"^1.1.1\"\n      },\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      }\n    },\n    \"node_modules/@eslint/core\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz\",\n      \"integrity\": \"sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@types/json-schema\": \"^7.0.15\"\n      },\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      }\n    },\n    \"node_modules/@eslint/js\": {\n      \"version\": \"10.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz\",\n      \"integrity\": \"sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      },\n      \"funding\": {\n        \"url\": \"https://eslint.org/donate\"\n      },\n      \"peerDependencies\": {\n        \"eslint\": \"^10.0.0\"\n      },\n      \"peerDependenciesMeta\": {\n        \"eslint\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/@eslint/object-schema\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz\",\n      \"integrity\": \"sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      }\n    },\n    \"node_modules/@eslint/plugin-kit\": {\n      \"version\": \"0.6.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz\",\n      \"integrity\": \"sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@humanwhocodes/object-schema\": \"^2.0.3\",\n        \"debug\": \"^4.3.1\",\n        \"minimatch\": \"^3.0.5\"\n      },\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      }\n    },\n    \"node_modules/@eslint/plugin-kit/node_modules/brace-expansion\": {\n      \"version\": \"1.1.12\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz\",\n      \"integrity\": \"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^1.0.0\",\n        \"concat-map\": \"0.0.1\"\n      }\n    },\n    \"node_modules/@eslint/plugin-kit/node_modules/minimatch\": {\n      \"version\": \"3.1.5\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz\",\n      \"integrity\": \"sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^1.1.7\"\n      },\n      \"engines\": {\n        \"node\": \"*\"\n      }\n    },\n    \"node_modules/@humanfs/core\": {\n      \"version\": \"0.19.1\",\n      \"resolved\": \"https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz\",\n      \"integrity\": \"sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=18.18.0\"\n      }\n    },\n    \"node_modules/@humanfs/node\": {\n      \"version\": \"0.16.7\",\n      \"resolved\": \"https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz\",\n      \"integrity\": \"sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@humanfs/core\": \"^0.19.1\",\n        \"@humanwhocodes/retry\": \"^0.4.0\"\n      },\n      \"engines\": {\n        \"node\": \">=18.18.0\"\n      }\n    },\n    \"node_modules/@humanwhocodes/module-importer\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz\",\n      \"integrity\": \"sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=12.22\"\n      },\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/nzakas\"\n      }\n    },\n    \"node_modules/@humanwhocodes/object-schema\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz\",\n      \"integrity\": \"sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==\",\n      \"deprecated\": \"Use @eslint/object-schema instead\",\n      \"dev\": true\n    },\n    \"node_modules/@humanwhocodes/retry\": {\n      \"version\": \"0.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz\",\n      \"integrity\": \"sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=18.18\"\n      },\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/nzakas\"\n      }\n    },\n    \"node_modules/@jridgewell/gen-mapping\": {\n      \"version\": \"0.3.13\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz\",\n      \"integrity\": \"sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/sourcemap-codec\": \"^1.5.0\",\n        \"@jridgewell/trace-mapping\": \"^0.3.24\"\n      }\n    },\n    \"node_modules/@jridgewell/remapping\": {\n      \"version\": \"2.3.5\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz\",\n      \"integrity\": \"sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/gen-mapping\": \"^0.3.5\",\n        \"@jridgewell/trace-mapping\": \"^0.3.24\"\n      }\n    },\n    \"node_modules/@jridgewell/resolve-uri\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz\",\n      \"integrity\": \"sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6.0.0\"\n      }\n    },\n    \"node_modules/@jridgewell/sourcemap-codec\": {\n      \"version\": \"1.5.5\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz\",\n      \"integrity\": \"sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@jridgewell/trace-mapping\": {\n      \"version\": \"0.3.31\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz\",\n      \"integrity\": \"sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/resolve-uri\": \"^3.1.0\",\n        \"@jridgewell/sourcemap-codec\": \"^1.4.14\"\n      }\n    },\n    \"node_modules/@koa/bodyparser\": {\n      \"version\": \"5.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@koa/bodyparser/-/bodyparser-5.1.2.tgz\",\n      \"integrity\": \"sha512-eGJm9/66iUX+LUH03Cz0e94unbSKrmSPCick4MO5UorAAomcjC5Kl+SkoZ6CSyPew3neMYjj7n+djnlGYBSJAg==\",\n      \"dependencies\": {\n        \"co-body\": \"^6.1.0\",\n        \"lodash.merge\": \"^4.6.2\",\n        \"type-is\": \"^1.6.18\"\n      },\n      \"engines\": {\n        \"node\": \">= 16\"\n      },\n      \"peerDependencies\": {\n        \"koa\": \"^2.14.1\"\n      }\n    },\n    \"node_modules/@koa/router\": {\n      \"version\": \"13.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@koa/router/-/router-13.1.1.tgz\",\n      \"integrity\": \"sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw==\",\n      \"deprecated\": \"Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.\",\n      \"dependencies\": {\n        \"debug\": \"^4.4.1\",\n        \"http-errors\": \"^2.0.0\",\n        \"koa-compose\": \"^4.1.0\",\n        \"path-to-regexp\": \"^6.3.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 18\"\n      }\n    },\n    \"node_modules/@lezer/common\": {\n      \"version\": \"1.5.1\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz\",\n      \"integrity\": \"sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@lezer/highlight\": {\n      \"version\": \"1.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz\",\n      \"integrity\": \"sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@lezer/common\": \"^1.3.0\"\n      }\n    },\n    \"node_modules/@lezer/javascript\": {\n      \"version\": \"1.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz\",\n      \"integrity\": \"sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@lezer/common\": \"^1.2.0\",\n        \"@lezer/highlight\": \"^1.1.3\",\n        \"@lezer/lr\": \"^1.3.0\"\n      }\n    },\n    \"node_modules/@lezer/lr\": {\n      \"version\": \"1.4.8\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz\",\n      \"integrity\": \"sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@lezer/common\": \"^1.0.0\"\n      }\n    },\n    \"node_modules/@lezer/yaml\": {\n      \"version\": \"1.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz\",\n      \"integrity\": \"sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@lezer/common\": \"^1.2.0\",\n        \"@lezer/highlight\": \"^1.0.0\",\n        \"@lezer/lr\": \"^1.4.0\"\n      }\n    },\n    \"node_modules/@marijn/find-cluster-break\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz\",\n      \"integrity\": \"sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@mongodb-js/saslprep\": {\n      \"version\": \"1.4.6\",\n      \"resolved\": \"https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz\",\n      \"integrity\": \"sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"sparse-bitfield\": \"^3.0.3\"\n      }\n    },\n    \"node_modules/@parcel/watcher\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz\",\n      \"integrity\": \"sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"detect-libc\": \"^2.0.3\",\n        \"is-glob\": \"^4.0.3\",\n        \"node-addon-api\": \"^7.0.0\",\n        \"picomatch\": \"^4.0.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      },\n      \"optionalDependencies\": {\n        \"@parcel/watcher-android-arm64\": \"2.5.6\",\n        \"@parcel/watcher-darwin-arm64\": \"2.5.6\",\n        \"@parcel/watcher-darwin-x64\": \"2.5.6\",\n        \"@parcel/watcher-freebsd-x64\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm-glibc\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm-musl\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm64-glibc\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm64-musl\": \"2.5.6\",\n        \"@parcel/watcher-linux-x64-glibc\": \"2.5.6\",\n        \"@parcel/watcher-linux-x64-musl\": \"2.5.6\",\n        \"@parcel/watcher-win32-arm64\": \"2.5.6\",\n        \"@parcel/watcher-win32-ia32\": \"2.5.6\",\n        \"@parcel/watcher-win32-x64\": \"2.5.6\"\n      }\n    },\n    \"node_modules/@parcel/watcher-android-arm64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz\",\n      \"integrity\": \"sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-darwin-arm64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz\",\n      \"integrity\": \"sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-darwin-x64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz\",\n      \"integrity\": \"sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-freebsd-x64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz\",\n      \"integrity\": \"sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-linux-arm-glibc\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz\",\n      \"integrity\": \"sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-linux-arm-musl\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz\",\n      \"integrity\": \"sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-linux-arm64-glibc\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz\",\n      \"integrity\": \"sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-linux-arm64-musl\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz\",\n      \"integrity\": \"sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-linux-x64-glibc\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz\",\n      \"integrity\": \"sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-linux-x64-musl\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz\",\n      \"integrity\": \"sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-win32-arm64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz\",\n      \"integrity\": \"sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-win32-ia32\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz\",\n      \"integrity\": \"sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==\",\n      \"cpu\": [\n        \"ia32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher-win32-x64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz\",\n      \"integrity\": \"sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">= 10.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/@parcel/watcher/node_modules/picomatch\": {\n      \"version\": \"4.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz\",\n      \"integrity\": \"sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/jonschlinkert\"\n      }\n    },\n    \"node_modules/@smithy/abort-controller\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz\",\n      \"integrity\": \"sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/config-resolver\": {\n      \"version\": \"4.4.11\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz\",\n      \"integrity\": \"sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-config-provider\": \"^4.2.2\",\n        \"@smithy/util-endpoints\": \"^3.3.3\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/core\": {\n      \"version\": \"3.23.11\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/core/-/core-3.23.11.tgz\",\n      \"integrity\": \"sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/url-parser\": \"^4.2.12\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-body-length-browser\": \"^4.2.2\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"@smithy/util-stream\": \"^4.5.19\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"@smithy/uuid\": \"^1.1.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/credential-provider-imds\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz\",\n      \"integrity\": \"sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/url-parser\": \"^4.2.12\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/fetch-http-handler\": {\n      \"version\": \"5.3.15\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz\",\n      \"integrity\": \"sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/querystring-builder\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/hash-node\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz\",\n      \"integrity\": \"sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/invalid-dependency\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz\",\n      \"integrity\": \"sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/is-array-buffer\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz\",\n      \"integrity\": \"sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/middleware-content-length\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz\",\n      \"integrity\": \"sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/middleware-endpoint\": {\n      \"version\": \"4.4.25\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.25.tgz\",\n      \"integrity\": \"sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/core\": \"^3.23.11\",\n        \"@smithy/middleware-serde\": \"^4.2.14\",\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.7\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/url-parser\": \"^4.2.12\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/middleware-retry\": {\n      \"version\": \"4.4.42\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.42.tgz\",\n      \"integrity\": \"sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/service-error-classification\": \"^4.2.12\",\n        \"@smithy/smithy-client\": \"^4.12.5\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"@smithy/util-retry\": \"^4.2.12\",\n        \"@smithy/uuid\": \"^1.1.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/middleware-serde\": {\n      \"version\": \"4.2.14\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.14.tgz\",\n      \"integrity\": \"sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/core\": \"^3.23.11\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/middleware-stack\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz\",\n      \"integrity\": \"sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/node-config-provider\": {\n      \"version\": \"4.3.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz\",\n      \"integrity\": \"sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.7\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/node-http-handler\": {\n      \"version\": \"4.4.16\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.16.tgz\",\n      \"integrity\": \"sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/abort-controller\": \"^4.2.12\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/querystring-builder\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/property-provider\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz\",\n      \"integrity\": \"sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/protocol-http\": {\n      \"version\": \"5.3.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz\",\n      \"integrity\": \"sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/querystring-builder\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz\",\n      \"integrity\": \"sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-uri-escape\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/querystring-parser\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz\",\n      \"integrity\": \"sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/service-error-classification\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz\",\n      \"integrity\": \"sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/shared-ini-file-loader\": {\n      \"version\": \"4.4.7\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz\",\n      \"integrity\": \"sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/signature-v4\": {\n      \"version\": \"5.3.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz\",\n      \"integrity\": \"sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/is-array-buffer\": \"^4.2.2\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-hex-encoding\": \"^4.2.2\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"@smithy/util-uri-escape\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/smithy-client\": {\n      \"version\": \"4.12.5\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.5.tgz\",\n      \"integrity\": \"sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/core\": \"^3.23.11\",\n        \"@smithy/middleware-endpoint\": \"^4.4.25\",\n        \"@smithy/middleware-stack\": \"^4.2.12\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-stream\": \"^4.5.19\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/types\": {\n      \"version\": \"4.13.1\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz\",\n      \"integrity\": \"sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/url-parser\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz\",\n      \"integrity\": \"sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/querystring-parser\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-base64\": {\n      \"version\": \"4.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz\",\n      \"integrity\": \"sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-body-length-browser\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz\",\n      \"integrity\": \"sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-body-length-node\": {\n      \"version\": \"4.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz\",\n      \"integrity\": \"sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-buffer-from\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz\",\n      \"integrity\": \"sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/is-array-buffer\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-config-provider\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz\",\n      \"integrity\": \"sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-defaults-mode-browser\": {\n      \"version\": \"4.3.41\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.41.tgz\",\n      \"integrity\": \"sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/smithy-client\": \"^4.12.5\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-defaults-mode-node\": {\n      \"version\": \"4.2.44\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.44.tgz\",\n      \"integrity\": \"sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/config-resolver\": \"^4.4.11\",\n        \"@smithy/credential-provider-imds\": \"^4.2.12\",\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/smithy-client\": \"^4.12.5\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-endpoints\": {\n      \"version\": \"3.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz\",\n      \"integrity\": \"sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-hex-encoding\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz\",\n      \"integrity\": \"sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-middleware\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz\",\n      \"integrity\": \"sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-retry\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz\",\n      \"integrity\": \"sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/service-error-classification\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-stream\": {\n      \"version\": \"4.5.19\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.19.tgz\",\n      \"integrity\": \"sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/fetch-http-handler\": \"^5.3.15\",\n        \"@smithy/node-http-handler\": \"^4.4.16\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"@smithy/util-hex-encoding\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-uri-escape\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz\",\n      \"integrity\": \"sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/util-utf8\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz\",\n      \"integrity\": \"sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@smithy/uuid\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz\",\n      \"integrity\": \"sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"tslib\": \"^2.6.2\"\n      },\n      \"engines\": {\n        \"node\": \">=18.0.0\"\n      }\n    },\n    \"node_modules/@tailwindcss/cli\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz\",\n      \"integrity\": \"sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@parcel/watcher\": \"^2.5.1\",\n        \"@tailwindcss/node\": \"4.2.1\",\n        \"@tailwindcss/oxide\": \"4.2.1\",\n        \"enhanced-resolve\": \"^5.19.0\",\n        \"mri\": \"^1.2.0\",\n        \"picocolors\": \"^1.1.1\",\n        \"tailwindcss\": \"4.2.1\"\n      },\n      \"bin\": {\n        \"tailwindcss\": \"dist/index.mjs\"\n      }\n    },\n    \"node_modules/@tailwindcss/forms\": {\n      \"version\": \"0.5.11\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz\",\n      \"integrity\": \"sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"mini-svg-data-uri\": \"^1.2.3\"\n      },\n      \"peerDependencies\": {\n        \"tailwindcss\": \">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1\"\n      }\n    },\n    \"node_modules/@tailwindcss/node\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz\",\n      \"integrity\": \"sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/remapping\": \"^2.3.5\",\n        \"enhanced-resolve\": \"^5.19.0\",\n        \"jiti\": \"^2.6.1\",\n        \"lightningcss\": \"1.31.1\",\n        \"magic-string\": \"^0.30.21\",\n        \"source-map-js\": \"^1.2.1\",\n        \"tailwindcss\": \"4.2.1\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz\",\n      \"integrity\": \"sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 20\"\n      },\n      \"optionalDependencies\": {\n        \"@tailwindcss/oxide-android-arm64\": \"4.2.1\",\n        \"@tailwindcss/oxide-darwin-arm64\": \"4.2.1\",\n        \"@tailwindcss/oxide-darwin-x64\": \"4.2.1\",\n        \"@tailwindcss/oxide-freebsd-x64\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-arm-gnueabihf\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-arm64-gnu\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-arm64-musl\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-x64-gnu\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-x64-musl\": \"4.2.1\",\n        \"@tailwindcss/oxide-wasm32-wasi\": \"4.2.1\",\n        \"@tailwindcss/oxide-win32-arm64-msvc\": \"4.2.1\",\n        \"@tailwindcss/oxide-win32-x64-msvc\": \"4.2.1\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-android-arm64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz\",\n      \"integrity\": \"sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-darwin-arm64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz\",\n      \"integrity\": \"sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-darwin-x64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz\",\n      \"integrity\": \"sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-freebsd-x64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz\",\n      \"integrity\": \"sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz\",\n      \"integrity\": \"sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-linux-arm64-gnu\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz\",\n      \"integrity\": \"sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-linux-arm64-musl\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz\",\n      \"integrity\": \"sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-linux-x64-gnu\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz\",\n      \"integrity\": \"sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-linux-x64-musl\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz\",\n      \"integrity\": \"sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-wasm32-wasi\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz\",\n      \"integrity\": \"sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==\",\n      \"bundleDependencies\": [\n        \"@napi-rs/wasm-runtime\",\n        \"@emnapi/core\",\n        \"@emnapi/runtime\",\n        \"@tybys/wasm-util\",\n        \"@emnapi/wasi-threads\",\n        \"tslib\"\n      ],\n      \"cpu\": [\n        \"wasm32\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"@emnapi/core\": \"^1.8.1\",\n        \"@emnapi/runtime\": \"^1.8.1\",\n        \"@emnapi/wasi-threads\": \"^1.1.0\",\n        \"@napi-rs/wasm-runtime\": \"^1.1.1\",\n        \"@tybys/wasm-util\": \"^0.10.1\",\n        \"tslib\": \"^2.8.1\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-win32-arm64-msvc\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz\",\n      \"integrity\": \"sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@tailwindcss/oxide-win32-x64-msvc\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz\",\n      \"integrity\": \"sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">= 20\"\n      }\n    },\n    \"node_modules/@types/accepts\": {\n      \"version\": \"1.3.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz\",\n      \"integrity\": \"sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/body-parser\": {\n      \"version\": \"1.19.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz\",\n      \"integrity\": \"sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/connect\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/connect\": {\n      \"version\": \"3.4.38\",\n      \"resolved\": \"https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz\",\n      \"integrity\": \"sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/content-disposition\": {\n      \"version\": \"0.5.9\",\n      \"resolved\": \"https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz\",\n      \"integrity\": \"sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/cookies\": {\n      \"version\": \"0.9.2\",\n      \"resolved\": \"https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz\",\n      \"integrity\": \"sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/connect\": \"*\",\n        \"@types/express\": \"*\",\n        \"@types/keygrip\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/esrecurse\": {\n      \"version\": \"4.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz\",\n      \"integrity\": \"sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/estree\": {\n      \"version\": \"1.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz\",\n      \"integrity\": \"sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/express\": {\n      \"version\": \"5.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz\",\n      \"integrity\": \"sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/body-parser\": \"*\",\n        \"@types/express-serve-static-core\": \"^5.0.0\",\n        \"@types/serve-static\": \"^2\"\n      }\n    },\n    \"node_modules/@types/express-serve-static-core\": {\n      \"version\": \"5.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz\",\n      \"integrity\": \"sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/node\": \"*\",\n        \"@types/qs\": \"*\",\n        \"@types/range-parser\": \"*\",\n        \"@types/send\": \"*\"\n      }\n    },\n    \"node_modules/@types/http-assert\": {\n      \"version\": \"1.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz\",\n      \"integrity\": \"sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/http-errors\": {\n      \"version\": \"2.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz\",\n      \"integrity\": \"sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/json-schema\": {\n      \"version\": \"7.0.15\",\n      \"resolved\": \"https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz\",\n      \"integrity\": \"sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@types/jsonwebtoken\": {\n      \"version\": \"9.0.10\",\n      \"resolved\": \"https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz\",\n      \"integrity\": \"sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/ms\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/keygrip\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz\",\n      \"integrity\": \"sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/koa\": {\n      \"version\": \"2.15.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz\",\n      \"integrity\": \"sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/accepts\": \"*\",\n        \"@types/content-disposition\": \"*\",\n        \"@types/cookies\": \"*\",\n        \"@types/http-assert\": \"*\",\n        \"@types/http-errors\": \"*\",\n        \"@types/keygrip\": \"*\",\n        \"@types/koa-compose\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/koa-compose\": {\n      \"version\": \"3.2.9\",\n      \"resolved\": \"https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz\",\n      \"integrity\": \"sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/koa\": \"*\"\n      }\n    },\n    \"node_modules/@types/koa-compress\": {\n      \"version\": \"4.0.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/koa-compress/-/koa-compress-4.0.7.tgz\",\n      \"integrity\": \"sha512-NqP9qCBfXCu2+RYkGzEENBkqXWExOPeBEsvj3F0xtVxKDwwdfRRtVdpxJeTRAq2Ml3qlUnDbK8bKHWwe6V1kkg==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/koa\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/mithril\": {\n      \"version\": \"2.2.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/mithril/-/mithril-2.2.7.tgz\",\n      \"integrity\": \"sha512-uetxoYizBMHPELl6DSZUfO6Q/aOm+h0NUCv9bVAX2iAxfrdBSOvU9KKFl+McTtxR13F+BReYLY814pJsZvnSxg==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/ms\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz\",\n      \"integrity\": \"sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/node\": {\n      \"version\": \"25.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz\",\n      \"integrity\": \"sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"undici-types\": \"~7.18.0\"\n      }\n    },\n    \"node_modules/@types/qs\": {\n      \"version\": \"6.15.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz\",\n      \"integrity\": \"sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/range-parser\": {\n      \"version\": \"1.2.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz\",\n      \"integrity\": \"sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/seedrandom\": {\n      \"version\": \"3.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz\",\n      \"integrity\": \"sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==\",\n      \"dev\": true\n    },\n    \"node_modules/@types/send\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz\",\n      \"integrity\": \"sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/serve-static\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz\",\n      \"integrity\": \"sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"@types/http-errors\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@types/webidl-conversions\": {\n      \"version\": \"7.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz\",\n      \"integrity\": \"sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==\"\n    },\n    \"node_modules/@types/whatwg-url\": {\n      \"version\": \"8.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz\",\n      \"integrity\": \"sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==\",\n      \"dependencies\": {\n        \"@types/node\": \"*\",\n        \"@types/webidl-conversions\": \"*\"\n      }\n    },\n    \"node_modules/@typescript-eslint/project-service\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz\",\n      \"integrity\": \"sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@typescript-eslint/tsconfig-utils\": \"^8.56.1\",\n        \"@typescript-eslint/types\": \"^8.56.1\",\n        \"debug\": \"^4.4.3\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types\": {\n      \"version\": \"8.57.0\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz\",\n      \"integrity\": \"sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      }\n    },\n    \"node_modules/@typescript-eslint/tsconfig-utils\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz\",\n      \"integrity\": \"sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/accepts\": {\n      \"version\": \"1.3.8\",\n      \"resolved\": \"https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz\",\n      \"integrity\": \"sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==\",\n      \"dependencies\": {\n        \"mime-types\": \"~2.1.34\",\n        \"negotiator\": \"0.6.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/acorn\": {\n      \"version\": \"8.16.0\",\n      \"resolved\": \"https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz\",\n      \"integrity\": \"sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"acorn\": \"bin/acorn\"\n      },\n      \"engines\": {\n        \"node\": \">=0.4.0\"\n      }\n    },\n    \"node_modules/acorn-jsx\": {\n      \"version\": \"5.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz\",\n      \"integrity\": \"sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"peerDependencies\": {\n        \"acorn\": \"^6.0.0 || ^7.0.0 || ^8.0.0\"\n      }\n    },\n    \"node_modules/aggregate-error\": {\n      \"version\": \"3.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz\",\n      \"integrity\": \"sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==\",\n      \"dependencies\": {\n        \"clean-stack\": \"^2.0.0\",\n        \"indent-string\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/ajv\": {\n      \"version\": \"6.14.0\",\n      \"resolved\": \"https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz\",\n      \"integrity\": \"sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"fast-deep-equal\": \"^3.1.1\",\n        \"fast-json-stable-stringify\": \"^2.0.0\",\n        \"json-schema-traverse\": \"^0.4.1\",\n        \"uri-js\": \"^4.2.2\"\n      },\n      \"funding\": {\n        \"type\": \"github\",\n        \"url\": \"https://github.com/sponsors/epoberezkin\"\n      }\n    },\n    \"node_modules/balanced-match\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz\",\n      \"integrity\": \"sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==\",\n      \"dev\": true\n    },\n    \"node_modules/base64-js\": {\n      \"version\": \"1.5.1\",\n      \"resolved\": \"https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz\",\n      \"integrity\": \"sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ]\n    },\n    \"node_modules/boolbase\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz\",\n      \"integrity\": \"sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==\",\n      \"dev\": true\n    },\n    \"node_modules/bowser\": {\n      \"version\": \"2.14.1\",\n      \"resolved\": \"https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz\",\n      \"integrity\": \"sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==\",\n      \"optional\": true\n    },\n    \"node_modules/bson\": {\n      \"version\": \"4.7.2\",\n      \"resolved\": \"https://registry.npmjs.org/bson/-/bson-4.7.2.tgz\",\n      \"integrity\": \"sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==\",\n      \"dependencies\": {\n        \"buffer\": \"^5.6.0\"\n      },\n      \"engines\": {\n        \"node\": \">=6.9.0\"\n      }\n    },\n    \"node_modules/buffer\": {\n      \"version\": \"5.7.1\",\n      \"resolved\": \"https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz\",\n      \"integrity\": \"sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ],\n      \"dependencies\": {\n        \"base64-js\": \"^1.3.1\",\n        \"ieee754\": \"^1.1.13\"\n      }\n    },\n    \"node_modules/buffer-equal-constant-time\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz\",\n      \"integrity\": \"sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==\"\n    },\n    \"node_modules/bytes\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz\",\n      \"integrity\": \"sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==\",\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/cache-content-type\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz\",\n      \"integrity\": \"sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==\",\n      \"dependencies\": {\n        \"mime-types\": \"^2.1.18\",\n        \"ylru\": \"^1.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 6.0.0\"\n      }\n    },\n    \"node_modules/call-bind-apply-helpers\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz\",\n      \"integrity\": \"sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==\",\n      \"dependencies\": {\n        \"es-errors\": \"^1.3.0\",\n        \"function-bind\": \"^1.1.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/call-bound\": {\n      \"version\": \"1.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz\",\n      \"integrity\": \"sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==\",\n      \"dependencies\": {\n        \"call-bind-apply-helpers\": \"^1.0.2\",\n        \"get-intrinsic\": \"^1.3.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/clean-stack\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz\",\n      \"integrity\": \"sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/co\": {\n      \"version\": \"4.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/co/-/co-4.6.0.tgz\",\n      \"integrity\": \"sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==\",\n      \"engines\": {\n        \"iojs\": \">= 1.0.0\",\n        \"node\": \">= 0.12.0\"\n      }\n    },\n    \"node_modules/co-body\": {\n      \"version\": \"6.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/co-body/-/co-body-6.1.0.tgz\",\n      \"integrity\": \"sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==\",\n      \"dependencies\": {\n        \"inflation\": \"^2.0.0\",\n        \"qs\": \"^6.5.2\",\n        \"raw-body\": \"^2.3.3\",\n        \"type-is\": \"^1.6.16\"\n      }\n    },\n    \"node_modules/commander\": {\n      \"version\": \"7.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/commander/-/commander-7.2.0.tgz\",\n      \"integrity\": \"sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/compressible\": {\n      \"version\": \"2.0.18\",\n      \"resolved\": \"https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz\",\n      \"integrity\": \"sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==\",\n      \"dependencies\": {\n        \"mime-db\": \">= 1.43.0 < 2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/concat-map\": {\n      \"version\": \"0.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz\",\n      \"integrity\": \"sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/content-disposition\": {\n      \"version\": \"0.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz\",\n      \"integrity\": \"sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==\",\n      \"dependencies\": {\n        \"safe-buffer\": \"5.2.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/content-type\": {\n      \"version\": \"1.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz\",\n      \"integrity\": \"sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/cookies\": {\n      \"version\": \"0.9.1\",\n      \"resolved\": \"https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz\",\n      \"integrity\": \"sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==\",\n      \"dependencies\": {\n        \"depd\": \"~2.0.0\",\n        \"keygrip\": \"~1.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/crelt\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz\",\n      \"integrity\": \"sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==\",\n      \"dev\": true\n    },\n    \"node_modules/cross-spawn\": {\n      \"version\": \"7.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz\",\n      \"integrity\": \"sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"path-key\": \"^3.1.0\",\n        \"shebang-command\": \"^2.0.0\",\n        \"which\": \"^2.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/css-select\": {\n      \"version\": \"5.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz\",\n      \"integrity\": \"sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"boolbase\": \"^1.0.0\",\n        \"css-what\": \"^6.1.0\",\n        \"domhandler\": \"^5.0.2\",\n        \"domutils\": \"^3.0.1\",\n        \"nth-check\": \"^2.0.1\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/fb55\"\n      }\n    },\n    \"node_modules/css-tree\": {\n      \"version\": \"2.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz\",\n      \"integrity\": \"sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"mdn-data\": \"2.0.30\",\n        \"source-map-js\": \"^1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \"^10 || ^12.20.0 || ^14.13.0 || >=15.0.0\"\n      }\n    },\n    \"node_modules/css-what\": {\n      \"version\": \"6.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz\",\n      \"integrity\": \"sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">= 6\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/fb55\"\n      }\n    },\n    \"node_modules/csso\": {\n      \"version\": \"5.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/csso/-/csso-5.0.5.tgz\",\n      \"integrity\": \"sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"css-tree\": \"~2.2.0\"\n      },\n      \"engines\": {\n        \"node\": \"^10 || ^12.20.0 || ^14.13.0 || >=15.0.0\",\n        \"npm\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/csso/node_modules/css-tree\": {\n      \"version\": \"2.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz\",\n      \"integrity\": \"sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"mdn-data\": \"2.0.28\",\n        \"source-map-js\": \"^1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \"^10 || ^12.20.0 || ^14.13.0 || >=15.0.0\",\n        \"npm\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/csso/node_modules/mdn-data\": {\n      \"version\": \"2.0.28\",\n      \"resolved\": \"https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz\",\n      \"integrity\": \"sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==\",\n      \"dev\": true\n    },\n    \"node_modules/debug\": {\n      \"version\": \"4.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/debug/-/debug-4.4.3.tgz\",\n      \"integrity\": \"sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==\",\n      \"dependencies\": {\n        \"ms\": \"^2.1.3\"\n      },\n      \"engines\": {\n        \"node\": \">=6.0\"\n      },\n      \"peerDependenciesMeta\": {\n        \"supports-color\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/deep-equal\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz\",\n      \"integrity\": \"sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==\"\n    },\n    \"node_modules/deep-is\": {\n      \"version\": \"0.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz\",\n      \"integrity\": \"sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==\",\n      \"dev\": true\n    },\n    \"node_modules/delegates\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz\",\n      \"integrity\": \"sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==\"\n    },\n    \"node_modules/depd\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/depd/-/depd-2.0.0.tgz\",\n      \"integrity\": \"sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==\",\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/destroy\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz\",\n      \"integrity\": \"sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==\",\n      \"engines\": {\n        \"node\": \">= 0.8\",\n        \"npm\": \"1.2.8000 || >= 1.4.16\"\n      }\n    },\n    \"node_modules/detect-libc\": {\n      \"version\": \"2.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz\",\n      \"integrity\": \"sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/dom-serializer\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz\",\n      \"integrity\": \"sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"domelementtype\": \"^2.3.0\",\n        \"domhandler\": \"^5.0.2\",\n        \"entities\": \"^4.2.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/cheeriojs/dom-serializer?sponsor=1\"\n      }\n    },\n    \"node_modules/domelementtype\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz\",\n      \"integrity\": \"sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==\",\n      \"dev\": true,\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/fb55\"\n        }\n      ]\n    },\n    \"node_modules/domhandler\": {\n      \"version\": \"5.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz\",\n      \"integrity\": \"sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"domelementtype\": \"^2.3.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/fb55/domhandler?sponsor=1\"\n      }\n    },\n    \"node_modules/domutils\": {\n      \"version\": \"3.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz\",\n      \"integrity\": \"sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"dom-serializer\": \"^2.0.0\",\n        \"domelementtype\": \"^2.3.0\",\n        \"domhandler\": \"^5.0.3\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/fb55/domutils?sponsor=1\"\n      }\n    },\n    \"node_modules/dunder-proto\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz\",\n      \"integrity\": \"sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==\",\n      \"dependencies\": {\n        \"call-bind-apply-helpers\": \"^1.0.1\",\n        \"es-errors\": \"^1.3.0\",\n        \"gopd\": \"^1.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/ecdsa-sig-formatter\": {\n      \"version\": \"1.0.11\",\n      \"resolved\": \"https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz\",\n      \"integrity\": \"sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==\",\n      \"dependencies\": {\n        \"safe-buffer\": \"^5.0.1\"\n      }\n    },\n    \"node_modules/ee-first\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz\",\n      \"integrity\": \"sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==\"\n    },\n    \"node_modules/encodeurl\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz\",\n      \"integrity\": \"sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==\",\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/enhanced-resolve\": {\n      \"version\": \"5.20.0\",\n      \"resolved\": \"https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz\",\n      \"integrity\": \"sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"graceful-fs\": \"^4.2.4\",\n        \"tapable\": \"^2.3.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10.13.0\"\n      }\n    },\n    \"node_modules/entities\": {\n      \"version\": \"4.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/entities/-/entities-4.5.0.tgz\",\n      \"integrity\": \"sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=0.12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/fb55/entities?sponsor=1\"\n      }\n    },\n    \"node_modules/es-define-property\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz\",\n      \"integrity\": \"sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/es-errors\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz\",\n      \"integrity\": \"sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/es-object-atoms\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz\",\n      \"integrity\": \"sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==\",\n      \"dependencies\": {\n        \"es-errors\": \"^1.3.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/esbuild\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz\",\n      \"integrity\": \"sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==\",\n      \"dev\": true,\n      \"hasInstallScript\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"esbuild\": \"bin/esbuild\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"optionalDependencies\": {\n        \"@esbuild/aix-ppc64\": \"0.27.4\",\n        \"@esbuild/android-arm\": \"0.27.4\",\n        \"@esbuild/android-arm64\": \"0.27.4\",\n        \"@esbuild/android-x64\": \"0.27.4\",\n        \"@esbuild/darwin-arm64\": \"0.27.4\",\n        \"@esbuild/darwin-x64\": \"0.27.4\",\n        \"@esbuild/freebsd-arm64\": \"0.27.4\",\n        \"@esbuild/freebsd-x64\": \"0.27.4\",\n        \"@esbuild/linux-arm\": \"0.27.4\",\n        \"@esbuild/linux-arm64\": \"0.27.4\",\n        \"@esbuild/linux-ia32\": \"0.27.4\",\n        \"@esbuild/linux-loong64\": \"0.27.4\",\n        \"@esbuild/linux-mips64el\": \"0.27.4\",\n        \"@esbuild/linux-ppc64\": \"0.27.4\",\n        \"@esbuild/linux-riscv64\": \"0.27.4\",\n        \"@esbuild/linux-s390x\": \"0.27.4\",\n        \"@esbuild/linux-x64\": \"0.27.4\",\n        \"@esbuild/netbsd-arm64\": \"0.27.4\",\n        \"@esbuild/netbsd-x64\": \"0.27.4\",\n        \"@esbuild/openbsd-arm64\": \"0.27.4\",\n        \"@esbuild/openbsd-x64\": \"0.27.4\",\n        \"@esbuild/openharmony-arm64\": \"0.27.4\",\n        \"@esbuild/sunos-x64\": \"0.27.4\",\n        \"@esbuild/win32-arm64\": \"0.27.4\",\n        \"@esbuild/win32-ia32\": \"0.27.4\",\n        \"@esbuild/win32-x64\": \"0.27.4\"\n      }\n    },\n    \"node_modules/escape-html\": {\n      \"version\": \"1.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz\",\n      \"integrity\": \"sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==\"\n    },\n    \"node_modules/escape-string-regexp\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz\",\n      \"integrity\": \"sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/eslint\": {\n      \"version\": \"10.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz\",\n      \"integrity\": \"sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@eslint-community/eslint-utils\": \"^4.8.0\",\n        \"@eslint-community/regexpp\": \"^4.12.2\",\n        \"@eslint/config-array\": \"^0.23.3\",\n        \"@eslint/config-helpers\": \"^0.5.2\",\n        \"@eslint/core\": \"^1.1.1\",\n        \"@eslint/plugin-kit\": \"^0.6.1\",\n        \"@humanfs/node\": \"^0.16.6\",\n        \"@humanwhocodes/module-importer\": \"^1.0.1\",\n        \"@humanwhocodes/retry\": \"^0.4.2\",\n        \"@types/estree\": \"^1.0.6\",\n        \"ajv\": \"^6.14.0\",\n        \"cross-spawn\": \"^7.0.6\",\n        \"debug\": \"^4.3.2\",\n        \"escape-string-regexp\": \"^4.0.0\",\n        \"eslint-scope\": \"^9.1.2\",\n        \"eslint-visitor-keys\": \"^5.0.1\",\n        \"espree\": \"^11.1.1\",\n        \"esquery\": \"^1.7.0\",\n        \"esutils\": \"^2.0.2\",\n        \"fast-deep-equal\": \"^3.1.3\",\n        \"file-entry-cache\": \"^8.0.0\",\n        \"find-up\": \"^5.0.0\",\n        \"glob-parent\": \"^6.0.2\",\n        \"ignore\": \"^5.2.0\",\n        \"imurmurhash\": \"^0.1.4\",\n        \"is-glob\": \"^4.0.0\",\n        \"json-stable-stringify-without-jsonify\": \"^1.0.1\",\n        \"minimatch\": \"^10.2.4\",\n        \"natural-compare\": \"^1.4.0\",\n        \"optionator\": \"^0.9.3\"\n      },\n      \"bin\": {\n        \"eslint\": \"bin/eslint.js\"\n      },\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      },\n      \"funding\": {\n        \"url\": \"https://eslint.org/donate\"\n      },\n      \"peerDependencies\": {\n        \"jiti\": \"*\"\n      },\n      \"peerDependenciesMeta\": {\n        \"jiti\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/eslint-config-prettier\": {\n      \"version\": \"10.1.8\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz\",\n      \"integrity\": \"sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"eslint-config-prettier\": \"bin/cli.js\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint-config-prettier\"\n      },\n      \"peerDependencies\": {\n        \"eslint\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/eslint-scope\": {\n      \"version\": \"9.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz\",\n      \"integrity\": \"sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"@types/esrecurse\": \"^4.3.1\",\n        \"@types/estree\": \"^1.0.8\",\n        \"esrecurse\": \"^4.3.0\",\n        \"estraverse\": \"^5.2.0\"\n      },\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint\"\n      }\n    },\n    \"node_modules/eslint-visitor-keys\": {\n      \"version\": \"3.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz\",\n      \"integrity\": \"sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \"^12.22.0 || ^14.17.0 || >=16.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint\"\n      }\n    },\n    \"node_modules/eslint/node_modules/balanced-match\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz\",\n      \"integrity\": \"sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      }\n    },\n    \"node_modules/eslint/node_modules/brace-expansion\": {\n      \"version\": \"5.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz\",\n      \"integrity\": \"sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^4.0.2\"\n      },\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      }\n    },\n    \"node_modules/eslint/node_modules/eslint-visitor-keys\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz\",\n      \"integrity\": \"sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint\"\n      }\n    },\n    \"node_modules/eslint/node_modules/minimatch\": {\n      \"version\": \"10.2.4\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz\",\n      \"integrity\": \"sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==\",\n      \"dev\": true,\n      \"license\": \"BlueOak-1.0.0\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^5.0.2\"\n      },\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/espree\": {\n      \"version\": \"11.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/espree/-/espree-11.2.0.tgz\",\n      \"integrity\": \"sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"acorn\": \"^8.16.0\",\n        \"acorn-jsx\": \"^5.3.2\",\n        \"eslint-visitor-keys\": \"^5.0.1\"\n      },\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint\"\n      }\n    },\n    \"node_modules/espree/node_modules/eslint-visitor-keys\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz\",\n      \"integrity\": \"sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint\"\n      }\n    },\n    \"node_modules/espresso-iisojs\": {\n      \"version\": \"1.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/espresso-iisojs/-/espresso-iisojs-1.0.8.tgz\",\n      \"integrity\": \"sha512-S3D62BA/jBUCIQJ3VlBGMSxGwPyyV7ttduiJvyaD4dWyhiC/9TnJTaiurx4r8bqcYZ1J0KELliub4xo8neKjSA==\"\n    },\n    \"node_modules/esquery\": {\n      \"version\": \"1.7.0\",\n      \"resolved\": \"https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz\",\n      \"integrity\": \"sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==\",\n      \"dev\": true,\n      \"license\": \"BSD-3-Clause\",\n      \"dependencies\": {\n        \"estraverse\": \"^5.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">=0.10\"\n      }\n    },\n    \"node_modules/esrecurse\": {\n      \"version\": \"4.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz\",\n      \"integrity\": \"sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"estraverse\": \"^5.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">=4.0\"\n      }\n    },\n    \"node_modules/estraverse\": {\n      \"version\": \"5.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz\",\n      \"integrity\": \"sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"engines\": {\n        \"node\": \">=4.0\"\n      }\n    },\n    \"node_modules/esutils\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz\",\n      \"integrity\": \"sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/fast-deep-equal\": {\n      \"version\": \"3.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz\",\n      \"integrity\": \"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/fast-json-stable-stringify\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz\",\n      \"integrity\": \"sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/fast-levenshtein\": {\n      \"version\": \"2.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz\",\n      \"integrity\": \"sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==\",\n      \"dev\": true\n    },\n    \"node_modules/fast-xml-builder\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz\",\n      \"integrity\": \"sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/NaturalIntelligence\"\n        }\n      ],\n      \"optional\": true,\n      \"dependencies\": {\n        \"path-expression-matcher\": \"^1.1.3\"\n      }\n    },\n    \"node_modules/fast-xml-parser\": {\n      \"version\": \"5.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz\",\n      \"integrity\": \"sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/NaturalIntelligence\"\n        }\n      ],\n      \"optional\": true,\n      \"dependencies\": {\n        \"fast-xml-builder\": \"^1.0.0\",\n        \"strnum\": \"^2.1.2\"\n      },\n      \"bin\": {\n        \"fxparser\": \"src/cli/cli.js\"\n      }\n    },\n    \"node_modules/file-entry-cache\": {\n      \"version\": \"8.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz\",\n      \"integrity\": \"sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"flat-cache\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=16.0.0\"\n      }\n    },\n    \"node_modules/find-up\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz\",\n      \"integrity\": \"sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"locate-path\": \"^6.0.0\",\n        \"path-exists\": \"^4.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/flat-cache\": {\n      \"version\": \"4.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz\",\n      \"integrity\": \"sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"flatted\": \"^3.2.9\",\n        \"keyv\": \"^4.5.4\"\n      },\n      \"engines\": {\n        \"node\": \">=16\"\n      }\n    },\n    \"node_modules/flatted\": {\n      \"version\": \"3.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz\",\n      \"integrity\": \"sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==\",\n      \"dev\": true\n    },\n    \"node_modules/fresh\": {\n      \"version\": \"0.5.2\",\n      \"resolved\": \"https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz\",\n      \"integrity\": \"sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/function-bind\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz\",\n      \"integrity\": \"sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==\",\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/generator-function\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz\",\n      \"integrity\": \"sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/get-intrinsic\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz\",\n      \"integrity\": \"sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==\",\n      \"dependencies\": {\n        \"call-bind-apply-helpers\": \"^1.0.2\",\n        \"es-define-property\": \"^1.0.1\",\n        \"es-errors\": \"^1.3.0\",\n        \"es-object-atoms\": \"^1.1.1\",\n        \"function-bind\": \"^1.1.2\",\n        \"get-proto\": \"^1.0.1\",\n        \"gopd\": \"^1.2.0\",\n        \"has-symbols\": \"^1.1.0\",\n        \"hasown\": \"^2.0.2\",\n        \"math-intrinsics\": \"^1.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/get-proto\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz\",\n      \"integrity\": \"sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==\",\n      \"dependencies\": {\n        \"dunder-proto\": \"^1.0.1\",\n        \"es-object-atoms\": \"^1.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/glob-parent\": {\n      \"version\": \"6.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz\",\n      \"integrity\": \"sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"is-glob\": \"^4.0.3\"\n      },\n      \"engines\": {\n        \"node\": \">=10.13.0\"\n      }\n    },\n    \"node_modules/globals\": {\n      \"version\": \"17.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/globals/-/globals-17.4.0.tgz\",\n      \"integrity\": \"sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/gopd\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz\",\n      \"integrity\": \"sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/graceful-fs\": {\n      \"version\": \"4.2.11\",\n      \"resolved\": \"https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz\",\n      \"integrity\": \"sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/has-symbols\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz\",\n      \"integrity\": \"sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/has-tostringtag\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz\",\n      \"integrity\": \"sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==\",\n      \"dependencies\": {\n        \"has-symbols\": \"^1.0.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/hasown\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz\",\n      \"integrity\": \"sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==\",\n      \"dependencies\": {\n        \"function-bind\": \"^1.1.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/http-assert\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz\",\n      \"integrity\": \"sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==\",\n      \"dependencies\": {\n        \"deep-equal\": \"~1.0.1\",\n        \"http-errors\": \"~1.8.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/http-assert/node_modules/depd\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n      \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/http-assert/node_modules/http-errors\": {\n      \"version\": \"1.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz\",\n      \"integrity\": \"sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==\",\n      \"dependencies\": {\n        \"depd\": \"~1.1.2\",\n        \"inherits\": \"2.0.4\",\n        \"setprototypeof\": \"1.2.0\",\n        \"statuses\": \">= 1.5.0 < 2\",\n        \"toidentifier\": \"1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/http-assert/node_modules/statuses\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n      \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/http-errors\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz\",\n      \"integrity\": \"sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==\",\n      \"dependencies\": {\n        \"depd\": \"~2.0.0\",\n        \"inherits\": \"~2.0.4\",\n        \"setprototypeof\": \"~1.2.0\",\n        \"statuses\": \"~2.0.2\",\n        \"toidentifier\": \"~1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/express\"\n      }\n    },\n    \"node_modules/iconv-lite\": {\n      \"version\": \"0.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz\",\n      \"integrity\": \"sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==\",\n      \"dependencies\": {\n        \"safer-buffer\": \">= 2.1.2 < 3.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/ieee754\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz\",\n      \"integrity\": \"sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ]\n    },\n    \"node_modules/ignore\": {\n      \"version\": \"5.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz\",\n      \"integrity\": \"sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">= 4\"\n      }\n    },\n    \"node_modules/imurmurhash\": {\n      \"version\": \"0.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz\",\n      \"integrity\": \"sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=0.8.19\"\n      }\n    },\n    \"node_modules/indent-string\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz\",\n      \"integrity\": \"sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/inflation\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz\",\n      \"integrity\": \"sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==\",\n      \"engines\": {\n        \"node\": \">= 0.8.0\"\n      }\n    },\n    \"node_modules/inherits\": {\n      \"version\": \"2.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz\",\n      \"integrity\": \"sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==\"\n    },\n    \"node_modules/ip-address\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz\",\n      \"integrity\": \"sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==\",\n      \"engines\": {\n        \"node\": \">= 12\"\n      }\n    },\n    \"node_modules/ipaddr.js\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz\",\n      \"integrity\": \"sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==\",\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/is-extglob\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz\",\n      \"integrity\": \"sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/is-generator-function\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz\",\n      \"integrity\": \"sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==\",\n      \"dependencies\": {\n        \"call-bound\": \"^1.0.4\",\n        \"generator-function\": \"^2.0.0\",\n        \"get-proto\": \"^1.0.1\",\n        \"has-tostringtag\": \"^1.0.2\",\n        \"safe-regex-test\": \"^1.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/is-glob\": {\n      \"version\": \"4.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz\",\n      \"integrity\": \"sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"is-extglob\": \"^2.1.1\"\n      },\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/is-regex\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz\",\n      \"integrity\": \"sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==\",\n      \"dependencies\": {\n        \"call-bound\": \"^1.0.2\",\n        \"gopd\": \"^1.2.0\",\n        \"has-tostringtag\": \"^1.0.2\",\n        \"hasown\": \"^2.0.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/isexe\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz\",\n      \"integrity\": \"sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==\",\n      \"dev\": true,\n      \"license\": \"ISC\"\n    },\n    \"node_modules/jiti\": {\n      \"version\": \"2.6.1\",\n      \"resolved\": \"https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz\",\n      \"integrity\": \"sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"jiti\": \"lib/jiti-cli.mjs\"\n      }\n    },\n    \"node_modules/json-buffer\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz\",\n      \"integrity\": \"sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/json-schema-traverse\": {\n      \"version\": \"0.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz\",\n      \"integrity\": \"sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/json-stable-stringify-without-jsonify\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz\",\n      \"integrity\": \"sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==\",\n      \"dev\": true\n    },\n    \"node_modules/jsonwebtoken\": {\n      \"version\": \"9.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz\",\n      \"integrity\": \"sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==\",\n      \"dependencies\": {\n        \"jws\": \"^4.0.1\",\n        \"lodash.includes\": \"^4.3.0\",\n        \"lodash.isboolean\": \"^3.0.3\",\n        \"lodash.isinteger\": \"^4.0.4\",\n        \"lodash.isnumber\": \"^3.0.3\",\n        \"lodash.isplainobject\": \"^4.0.6\",\n        \"lodash.isstring\": \"^4.0.1\",\n        \"lodash.once\": \"^4.0.0\",\n        \"ms\": \"^2.1.1\",\n        \"semver\": \"^7.5.4\"\n      },\n      \"engines\": {\n        \"node\": \">=12\",\n        \"npm\": \">=6\"\n      }\n    },\n    \"node_modules/jwa\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz\",\n      \"integrity\": \"sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==\",\n      \"dependencies\": {\n        \"buffer-equal-constant-time\": \"^1.0.1\",\n        \"ecdsa-sig-formatter\": \"1.0.11\",\n        \"safe-buffer\": \"^5.0.1\"\n      }\n    },\n    \"node_modules/jws\": {\n      \"version\": \"4.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/jws/-/jws-4.0.1.tgz\",\n      \"integrity\": \"sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==\",\n      \"dependencies\": {\n        \"jwa\": \"^2.0.1\",\n        \"safe-buffer\": \"^5.0.1\"\n      }\n    },\n    \"node_modules/keygrip\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz\",\n      \"integrity\": \"sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==\",\n      \"dependencies\": {\n        \"tsscmp\": \"1.0.6\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/keyv\": {\n      \"version\": \"4.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz\",\n      \"integrity\": \"sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"json-buffer\": \"3.0.1\"\n      }\n    },\n    \"node_modules/koa\": {\n      \"version\": \"2.16.4\",\n      \"resolved\": \"https://registry.npmjs.org/koa/-/koa-2.16.4.tgz\",\n      \"integrity\": \"sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==\",\n      \"dependencies\": {\n        \"accepts\": \"^1.3.5\",\n        \"cache-content-type\": \"^1.0.0\",\n        \"content-disposition\": \"~0.5.2\",\n        \"content-type\": \"^1.0.4\",\n        \"cookies\": \"~0.9.0\",\n        \"debug\": \"^4.3.2\",\n        \"delegates\": \"^1.0.0\",\n        \"depd\": \"^2.0.0\",\n        \"destroy\": \"^1.0.4\",\n        \"encodeurl\": \"^1.0.2\",\n        \"escape-html\": \"^1.0.3\",\n        \"fresh\": \"~0.5.2\",\n        \"http-assert\": \"^1.3.0\",\n        \"http-errors\": \"^1.6.3\",\n        \"is-generator-function\": \"^1.0.7\",\n        \"koa-compose\": \"^4.1.0\",\n        \"koa-convert\": \"^2.0.0\",\n        \"on-finished\": \"^2.3.0\",\n        \"only\": \"~0.0.2\",\n        \"parseurl\": \"^1.3.2\",\n        \"statuses\": \"^1.5.0\",\n        \"type-is\": \"^1.6.16\",\n        \"vary\": \"^1.1.2\"\n      },\n      \"engines\": {\n        \"node\": \"^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4\"\n      }\n    },\n    \"node_modules/koa-compose\": {\n      \"version\": \"4.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz\",\n      \"integrity\": \"sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==\"\n    },\n    \"node_modules/koa-compress\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-compress/-/koa-compress-5.2.0.tgz\",\n      \"integrity\": \"sha512-RsRnI+v+/rs1lYpcAUcxowUzHYssf71qbMr0Mpdq1wktbtXDZmxBIgxJHtaEsBjSe4jiWYELpGFbASa2AemmOg==\",\n      \"dependencies\": {\n        \"bytes\": \"^3.1.2\",\n        \"compressible\": \"^2.0.18\",\n        \"http-errors\": \"^2.0.1\",\n        \"koa-is-json\": \"^1.0.0\",\n        \"negotiator\": \"^1.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 12\"\n      }\n    },\n    \"node_modules/koa-compress/node_modules/negotiator\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz\",\n      \"integrity\": \"sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/koa-convert\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz\",\n      \"integrity\": \"sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==\",\n      \"dependencies\": {\n        \"co\": \"^4.6.0\",\n        \"koa-compose\": \"^4.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10\"\n      }\n    },\n    \"node_modules/koa-is-json\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-is-json/-/koa-is-json-1.0.0.tgz\",\n      \"integrity\": \"sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==\"\n    },\n    \"node_modules/koa-jwt\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/koa-jwt/-/koa-jwt-4.0.4.tgz\",\n      \"integrity\": \"sha512-Tid9BQfpVtUG/8YZV38a+hDKll0pfVhfl7A/2cNaYThS1cxMFXylZzfARqHQqvNhHy9qM+qkxd4/z6EaIV4SAQ==\",\n      \"dependencies\": {\n        \"jsonwebtoken\": \"^9.0.0\",\n        \"koa-unless\": \"^1.0.7\",\n        \"p-any\": \"^2.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/koa-send\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz\",\n      \"integrity\": \"sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==\",\n      \"dependencies\": {\n        \"debug\": \"^4.1.1\",\n        \"http-errors\": \"^1.7.3\",\n        \"resolve-path\": \"^1.4.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/koa-send/node_modules/depd\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n      \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/koa-send/node_modules/http-errors\": {\n      \"version\": \"1.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz\",\n      \"integrity\": \"sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==\",\n      \"dependencies\": {\n        \"depd\": \"~1.1.2\",\n        \"inherits\": \"2.0.4\",\n        \"setprototypeof\": \"1.2.0\",\n        \"statuses\": \">= 1.5.0 < 2\",\n        \"toidentifier\": \"1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/koa-send/node_modules/statuses\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n      \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/koa-unless\": {\n      \"version\": \"1.0.7\",\n      \"resolved\": \"https://registry.npmjs.org/koa-unless/-/koa-unless-1.0.7.tgz\",\n      \"integrity\": \"sha512-NKiz+nk4KxSJFskiJMuJvxeA41Lcnx3d8Zy+8QETgifm4ab4aOeGD3RgR6bIz0FGNWwo3Fz0DtnK77mEIqHWxA==\"\n    },\n    \"node_modules/koa/node_modules/http-errors\": {\n      \"version\": \"1.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz\",\n      \"integrity\": \"sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==\",\n      \"dependencies\": {\n        \"depd\": \"~1.1.2\",\n        \"inherits\": \"2.0.4\",\n        \"setprototypeof\": \"1.2.0\",\n        \"statuses\": \">= 1.5.0 < 2\",\n        \"toidentifier\": \"1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/koa/node_modules/http-errors/node_modules/depd\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n      \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/koa/node_modules/statuses\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n      \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/levn\": {\n      \"version\": \"0.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/levn/-/levn-0.4.1.tgz\",\n      \"integrity\": \"sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"prelude-ls\": \"^1.2.1\",\n        \"type-check\": \"~0.4.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8.0\"\n      }\n    },\n    \"node_modules/lightningcss\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz\",\n      \"integrity\": \"sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==\",\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"dependencies\": {\n        \"detect-libc\": \"^2.0.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      },\n      \"optionalDependencies\": {\n        \"lightningcss-android-arm64\": \"1.31.1\",\n        \"lightningcss-darwin-arm64\": \"1.31.1\",\n        \"lightningcss-darwin-x64\": \"1.31.1\",\n        \"lightningcss-freebsd-x64\": \"1.31.1\",\n        \"lightningcss-linux-arm-gnueabihf\": \"1.31.1\",\n        \"lightningcss-linux-arm64-gnu\": \"1.31.1\",\n        \"lightningcss-linux-arm64-musl\": \"1.31.1\",\n        \"lightningcss-linux-x64-gnu\": \"1.31.1\",\n        \"lightningcss-linux-x64-musl\": \"1.31.1\",\n        \"lightningcss-win32-arm64-msvc\": \"1.31.1\",\n        \"lightningcss-win32-x64-msvc\": \"1.31.1\"\n      }\n    },\n    \"node_modules/lightningcss-android-arm64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz\",\n      \"integrity\": \"sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"android\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-darwin-arm64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz\",\n      \"integrity\": \"sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-darwin-x64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz\",\n      \"integrity\": \"sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"darwin\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-freebsd-x64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz\",\n      \"integrity\": \"sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"freebsd\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-linux-arm-gnueabihf\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz\",\n      \"integrity\": \"sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==\",\n      \"cpu\": [\n        \"arm\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-linux-arm64-gnu\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz\",\n      \"integrity\": \"sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-linux-arm64-musl\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz\",\n      \"integrity\": \"sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-linux-x64-gnu\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz\",\n      \"integrity\": \"sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-linux-x64-musl\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz\",\n      \"integrity\": \"sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"linux\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-win32-arm64-msvc\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz\",\n      \"integrity\": \"sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==\",\n      \"cpu\": [\n        \"arm64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/lightningcss-win32-x64-msvc\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz\",\n      \"integrity\": \"sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==\",\n      \"cpu\": [\n        \"x64\"\n      ],\n      \"dev\": true,\n      \"license\": \"MPL-2.0\",\n      \"optional\": true,\n      \"os\": [\n        \"win32\"\n      ],\n      \"engines\": {\n        \"node\": \">= 12.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/parcel\"\n      }\n    },\n    \"node_modules/locate-path\": {\n      \"version\": \"6.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz\",\n      \"integrity\": \"sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"p-locate\": \"^5.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/lodash.includes\": {\n      \"version\": \"4.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz\",\n      \"integrity\": \"sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==\"\n    },\n    \"node_modules/lodash.isboolean\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz\",\n      \"integrity\": \"sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==\"\n    },\n    \"node_modules/lodash.isinteger\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz\",\n      \"integrity\": \"sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==\"\n    },\n    \"node_modules/lodash.isnumber\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz\",\n      \"integrity\": \"sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==\"\n    },\n    \"node_modules/lodash.isplainobject\": {\n      \"version\": \"4.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz\",\n      \"integrity\": \"sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==\"\n    },\n    \"node_modules/lodash.isstring\": {\n      \"version\": \"4.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz\",\n      \"integrity\": \"sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==\"\n    },\n    \"node_modules/lodash.merge\": {\n      \"version\": \"4.6.2\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz\",\n      \"integrity\": \"sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==\"\n    },\n    \"node_modules/lodash.once\": {\n      \"version\": \"4.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz\",\n      \"integrity\": \"sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==\"\n    },\n    \"node_modules/magic-string\": {\n      \"version\": \"0.30.21\",\n      \"resolved\": \"https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz\",\n      \"integrity\": \"sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@jridgewell/sourcemap-codec\": \"^1.5.5\"\n      }\n    },\n    \"node_modules/math-intrinsics\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz\",\n      \"integrity\": \"sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      }\n    },\n    \"node_modules/mdn-data\": {\n      \"version\": \"2.0.30\",\n      \"resolved\": \"https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz\",\n      \"integrity\": \"sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==\",\n      \"dev\": true\n    },\n    \"node_modules/media-typer\": {\n      \"version\": \"0.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz\",\n      \"integrity\": \"sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/memory-pager\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz\",\n      \"integrity\": \"sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==\",\n      \"optional\": true\n    },\n    \"node_modules/mime-db\": {\n      \"version\": \"1.54.0\",\n      \"resolved\": \"https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz\",\n      \"integrity\": \"sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/mime-types\": {\n      \"version\": \"2.1.35\",\n      \"resolved\": \"https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz\",\n      \"integrity\": \"sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==\",\n      \"dependencies\": {\n        \"mime-db\": \"1.52.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/mime-types/node_modules/mime-db\": {\n      \"version\": \"1.52.0\",\n      \"resolved\": \"https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz\",\n      \"integrity\": \"sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/mini-svg-data-uri\": {\n      \"version\": \"1.4.4\",\n      \"resolved\": \"https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz\",\n      \"integrity\": \"sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"mini-svg-data-uri\": \"cli.js\"\n      }\n    },\n    \"node_modules/mithril\": {\n      \"version\": \"2.3.8\",\n      \"resolved\": \"https://registry.npmjs.org/mithril/-/mithril-2.3.8.tgz\",\n      \"integrity\": \"sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ==\",\n      \"dev\": true\n    },\n    \"node_modules/mongodb\": {\n      \"version\": \"4.17.2\",\n      \"resolved\": \"https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz\",\n      \"integrity\": \"sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==\",\n      \"dependencies\": {\n        \"bson\": \"^4.7.2\",\n        \"mongodb-connection-string-url\": \"^2.6.0\",\n        \"socks\": \"^2.7.1\"\n      },\n      \"engines\": {\n        \"node\": \">=12.9.0\"\n      },\n      \"optionalDependencies\": {\n        \"@aws-sdk/credential-providers\": \"^3.186.0\",\n        \"@mongodb-js/saslprep\": \"^1.1.0\"\n      }\n    },\n    \"node_modules/mongodb-connection-string-url\": {\n      \"version\": \"2.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz\",\n      \"integrity\": \"sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==\",\n      \"dependencies\": {\n        \"@types/whatwg-url\": \"^8.2.1\",\n        \"whatwg-url\": \"^11.0.0\"\n      }\n    },\n    \"node_modules/mri\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/mri/-/mri-1.2.0.tgz\",\n      \"integrity\": \"sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=4\"\n      }\n    },\n    \"node_modules/ms\": {\n      \"version\": \"2.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/ms/-/ms-2.1.3.tgz\",\n      \"integrity\": \"sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==\"\n    },\n    \"node_modules/natural-compare\": {\n      \"version\": \"1.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz\",\n      \"integrity\": \"sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==\",\n      \"dev\": true\n    },\n    \"node_modules/negotiator\": {\n      \"version\": \"0.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz\",\n      \"integrity\": \"sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/node-addon-api\": {\n      \"version\": \"7.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz\",\n      \"integrity\": \"sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/nth-check\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz\",\n      \"integrity\": \"sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"boolbase\": \"^1.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/fb55/nth-check?sponsor=1\"\n      }\n    },\n    \"node_modules/object-inspect\": {\n      \"version\": \"1.13.4\",\n      \"resolved\": \"https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz\",\n      \"integrity\": \"sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==\",\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/on-finished\": {\n      \"version\": \"2.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz\",\n      \"integrity\": \"sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==\",\n      \"dependencies\": {\n        \"ee-first\": \"1.1.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/only\": {\n      \"version\": \"0.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/only/-/only-0.0.2.tgz\",\n      \"integrity\": \"sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==\"\n    },\n    \"node_modules/optionator\": {\n      \"version\": \"0.9.4\",\n      \"resolved\": \"https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz\",\n      \"integrity\": \"sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"deep-is\": \"^0.1.3\",\n        \"fast-levenshtein\": \"^2.0.6\",\n        \"levn\": \"^0.4.1\",\n        \"prelude-ls\": \"^1.2.1\",\n        \"type-check\": \"^0.4.0\",\n        \"word-wrap\": \"^1.2.5\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8.0\"\n      }\n    },\n    \"node_modules/p-any\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-any/-/p-any-2.1.0.tgz\",\n      \"integrity\": \"sha512-JAERcaMBLYKMq+voYw36+x5Dgh47+/o7yuv2oQYuSSUml4YeqJEFznBrY2UeEkoSHqBua6hz518n/PsowTYLLg==\",\n      \"dependencies\": {\n        \"p-cancelable\": \"^2.0.0\",\n        \"p-some\": \"^4.0.0\",\n        \"type-fest\": \"^0.3.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/p-any/node_modules/type-fest\": {\n      \"version\": \"0.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz\",\n      \"integrity\": \"sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/p-cancelable\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz\",\n      \"integrity\": \"sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/p-limit\": {\n      \"version\": \"3.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz\",\n      \"integrity\": \"sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"yocto-queue\": \"^0.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/p-locate\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz\",\n      \"integrity\": \"sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"p-limit\": \"^3.0.2\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    },\n    \"node_modules/p-some\": {\n      \"version\": \"4.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-some/-/p-some-4.1.0.tgz\",\n      \"integrity\": \"sha512-MF/HIbq6GeBqTrTIl5OJubzkGU+qfFhAFi0gnTAK6rgEIJIknEiABHOTtQu4e6JiXjIwuMPMUFQzyHh5QjCl1g==\",\n      \"dependencies\": {\n        \"aggregate-error\": \"^3.0.0\",\n        \"p-cancelable\": \"^2.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/parseurl\": {\n      \"version\": \"1.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz\",\n      \"integrity\": \"sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==\",\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/path-exists\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz\",\n      \"integrity\": \"sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/path-expression-matcher\": {\n      \"version\": \"1.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz\",\n      \"integrity\": \"sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/NaturalIntelligence\"\n        }\n      ],\n      \"optional\": true,\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      }\n    },\n    \"node_modules/path-is-absolute\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz\",\n      \"integrity\": \"sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/path-key\": {\n      \"version\": \"3.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz\",\n      \"integrity\": \"sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/path-to-regexp\": {\n      \"version\": \"6.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz\",\n      \"integrity\": \"sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/picocolors\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz\",\n      \"integrity\": \"sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==\",\n      \"dev\": true\n    },\n    \"node_modules/prelude-ls\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz\",\n      \"integrity\": \"sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">= 0.8.0\"\n      }\n    },\n    \"node_modules/prettier\": {\n      \"version\": \"3.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz\",\n      \"integrity\": \"sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"bin\": {\n        \"prettier\": \"bin/prettier.cjs\"\n      },\n      \"engines\": {\n        \"node\": \">=14\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/prettier/prettier?sponsor=1\"\n      }\n    },\n    \"node_modules/punycode\": {\n      \"version\": \"2.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz\",\n      \"integrity\": \"sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==\",\n      \"engines\": {\n        \"node\": \">=6\"\n      }\n    },\n    \"node_modules/qs\": {\n      \"version\": \"6.15.0\",\n      \"resolved\": \"https://registry.npmjs.org/qs/-/qs-6.15.0.tgz\",\n      \"integrity\": \"sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==\",\n      \"dependencies\": {\n        \"side-channel\": \"^1.1.0\"\n      },\n      \"engines\": {\n        \"node\": \">=0.6\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/raw-body\": {\n      \"version\": \"2.5.3\",\n      \"resolved\": \"https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz\",\n      \"integrity\": \"sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==\",\n      \"dependencies\": {\n        \"bytes\": \"~3.1.2\",\n        \"http-errors\": \"~2.0.1\",\n        \"iconv-lite\": \"~0.4.24\",\n        \"unpipe\": \"~1.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/raw-body/node_modules/iconv-lite\": {\n      \"version\": \"0.4.24\",\n      \"resolved\": \"https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz\",\n      \"integrity\": \"sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==\",\n      \"dependencies\": {\n        \"safer-buffer\": \">= 2.1.2 < 3\"\n      },\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/resolve-path\": {\n      \"version\": \"1.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz\",\n      \"integrity\": \"sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==\",\n      \"dependencies\": {\n        \"http-errors\": \"~1.6.2\",\n        \"path-is-absolute\": \"1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/resolve-path/node_modules/depd\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n      \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/resolve-path/node_modules/http-errors\": {\n      \"version\": \"1.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz\",\n      \"integrity\": \"sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==\",\n      \"dependencies\": {\n        \"depd\": \"~1.1.2\",\n        \"inherits\": \"2.0.3\",\n        \"setprototypeof\": \"1.1.0\",\n        \"statuses\": \">= 1.4.0 < 2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/resolve-path/node_modules/inherits\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz\",\n      \"integrity\": \"sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==\"\n    },\n    \"node_modules/resolve-path/node_modules/setprototypeof\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz\",\n      \"integrity\": \"sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==\"\n    },\n    \"node_modules/resolve-path/node_modules/statuses\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n      \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\",\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/safe-buffer\": {\n      \"version\": \"5.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz\",\n      \"integrity\": \"sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/feross\"\n        },\n        {\n          \"type\": \"patreon\",\n          \"url\": \"https://www.patreon.com/feross\"\n        },\n        {\n          \"type\": \"consulting\",\n          \"url\": \"https://feross.org/support\"\n        }\n      ]\n    },\n    \"node_modules/safe-regex-test\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz\",\n      \"integrity\": \"sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==\",\n      \"dependencies\": {\n        \"call-bound\": \"^1.0.2\",\n        \"es-errors\": \"^1.3.0\",\n        \"is-regex\": \"^1.2.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/safer-buffer\": {\n      \"version\": \"2.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz\",\n      \"integrity\": \"sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==\"\n    },\n    \"node_modules/sax\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/sax/-/sax-1.5.0.tgz\",\n      \"integrity\": \"sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=11.0.0\"\n      }\n    },\n    \"node_modules/seedrandom\": {\n      \"version\": \"3.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz\",\n      \"integrity\": \"sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==\"\n    },\n    \"node_modules/semver\": {\n      \"version\": \"7.7.4\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.7.4.tgz\",\n      \"integrity\": \"sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==\",\n      \"bin\": {\n        \"semver\": \"bin/semver.js\"\n      },\n      \"engines\": {\n        \"node\": \">=10\"\n      }\n    },\n    \"node_modules/setprototypeof\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz\",\n      \"integrity\": \"sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==\"\n    },\n    \"node_modules/shebang-command\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz\",\n      \"integrity\": \"sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"shebang-regex\": \"^3.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/shebang-regex\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz\",\n      \"integrity\": \"sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=8\"\n      }\n    },\n    \"node_modules/side-channel\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz\",\n      \"integrity\": \"sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==\",\n      \"dependencies\": {\n        \"es-errors\": \"^1.3.0\",\n        \"object-inspect\": \"^1.13.3\",\n        \"side-channel-list\": \"^1.0.0\",\n        \"side-channel-map\": \"^1.0.1\",\n        \"side-channel-weakmap\": \"^1.0.2\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/side-channel-list\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz\",\n      \"integrity\": \"sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==\",\n      \"dependencies\": {\n        \"es-errors\": \"^1.3.0\",\n        \"object-inspect\": \"^1.13.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/side-channel-map\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz\",\n      \"integrity\": \"sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==\",\n      \"dependencies\": {\n        \"call-bound\": \"^1.0.2\",\n        \"es-errors\": \"^1.3.0\",\n        \"get-intrinsic\": \"^1.2.5\",\n        \"object-inspect\": \"^1.13.3\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/side-channel-weakmap\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz\",\n      \"integrity\": \"sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==\",\n      \"dependencies\": {\n        \"call-bound\": \"^1.0.2\",\n        \"es-errors\": \"^1.3.0\",\n        \"get-intrinsic\": \"^1.2.5\",\n        \"object-inspect\": \"^1.13.3\",\n        \"side-channel-map\": \"^1.0.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.4\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/ljharb\"\n      }\n    },\n    \"node_modules/smart-buffer\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz\",\n      \"integrity\": \"sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==\",\n      \"engines\": {\n        \"node\": \">= 6.0.0\",\n        \"npm\": \">= 3.0.0\"\n      }\n    },\n    \"node_modules/socks\": {\n      \"version\": \"2.8.7\",\n      \"resolved\": \"https://registry.npmjs.org/socks/-/socks-2.8.7.tgz\",\n      \"integrity\": \"sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==\",\n      \"dependencies\": {\n        \"ip-address\": \"^10.0.1\",\n        \"smart-buffer\": \"^4.2.0\"\n      },\n      \"engines\": {\n        \"node\": \">= 10.0.0\",\n        \"npm\": \">= 3.0.0\"\n      }\n    },\n    \"node_modules/source-map-js\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz\",\n      \"integrity\": \"sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==\",\n      \"dev\": true,\n      \"license\": \"BSD-3-Clause\",\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/sparse-bitfield\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz\",\n      \"integrity\": \"sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==\",\n      \"optional\": true,\n      \"dependencies\": {\n        \"memory-pager\": \"^1.0.2\"\n      }\n    },\n    \"node_modules/sql.js\": {\n      \"version\": \"1.14.1\",\n      \"resolved\": \"https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz\",\n      \"integrity\": \"sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==\",\n      \"dev\": true\n    },\n    \"node_modules/statuses\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz\",\n      \"integrity\": \"sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==\",\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/strnum\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz\",\n      \"integrity\": \"sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==\",\n      \"funding\": [\n        {\n          \"type\": \"github\",\n          \"url\": \"https://github.com/sponsors/NaturalIntelligence\"\n        }\n      ],\n      \"optional\": true\n    },\n    \"node_modules/style-mod\": {\n      \"version\": \"4.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz\",\n      \"integrity\": \"sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==\",\n      \"dev\": true\n    },\n    \"node_modules/svgo\": {\n      \"version\": \"3.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz\",\n      \"integrity\": \"sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"commander\": \"^7.2.0\",\n        \"css-select\": \"^5.1.0\",\n        \"css-tree\": \"^2.3.1\",\n        \"css-what\": \"^6.1.0\",\n        \"csso\": \"^5.0.5\",\n        \"picocolors\": \"^1.0.0\",\n        \"sax\": \"^1.5.0\"\n      },\n      \"bin\": {\n        \"svgo\": \"bin/svgo\"\n      },\n      \"engines\": {\n        \"node\": \">=14.0.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/svgo\"\n      }\n    },\n    \"node_modules/tailwindcss\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz\",\n      \"integrity\": \"sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==\",\n      \"dev\": true,\n      \"license\": \"MIT\"\n    },\n    \"node_modules/tapable\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz\",\n      \"integrity\": \"sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=6\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/webpack\"\n      }\n    },\n    \"node_modules/tinyglobby\": {\n      \"version\": \"0.2.15\",\n      \"resolved\": \"https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz\",\n      \"integrity\": \"sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"fdir\": \"^6.5.0\",\n        \"picomatch\": \"^4.0.3\"\n      },\n      \"engines\": {\n        \"node\": \">=12.0.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/SuperchupuDev\"\n      }\n    },\n    \"node_modules/tinyglobby/node_modules/fdir\": {\n      \"version\": \"6.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz\",\n      \"integrity\": \"sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=12.0.0\"\n      },\n      \"peerDependencies\": {\n        \"picomatch\": \"^3 || ^4\"\n      },\n      \"peerDependenciesMeta\": {\n        \"picomatch\": {\n          \"optional\": true\n        }\n      }\n    },\n    \"node_modules/tinyglobby/node_modules/picomatch\": {\n      \"version\": \"4.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz\",\n      \"integrity\": \"sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=12\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/jonschlinkert\"\n      }\n    },\n    \"node_modules/toidentifier\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz\",\n      \"integrity\": \"sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==\",\n      \"engines\": {\n        \"node\": \">=0.6\"\n      }\n    },\n    \"node_modules/tr46\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz\",\n      \"integrity\": \"sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==\",\n      \"dependencies\": {\n        \"punycode\": \"^2.1.1\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/tslib\": {\n      \"version\": \"2.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz\",\n      \"integrity\": \"sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==\",\n      \"optional\": true\n    },\n    \"node_modules/tsscmp\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz\",\n      \"integrity\": \"sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==\",\n      \"engines\": {\n        \"node\": \">=0.6.x\"\n      }\n    },\n    \"node_modules/type-check\": {\n      \"version\": \"0.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz\",\n      \"integrity\": \"sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==\",\n      \"dev\": true,\n      \"dependencies\": {\n        \"prelude-ls\": \"^1.2.1\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.8.0\"\n      }\n    },\n    \"node_modules/type-is\": {\n      \"version\": \"1.6.18\",\n      \"resolved\": \"https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz\",\n      \"integrity\": \"sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==\",\n      \"dependencies\": {\n        \"media-typer\": \"0.3.0\",\n        \"mime-types\": \"~2.1.24\"\n      },\n      \"engines\": {\n        \"node\": \">= 0.6\"\n      }\n    },\n    \"node_modules/typescript\": {\n      \"version\": \"5.9.3\",\n      \"resolved\": \"https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz\",\n      \"integrity\": \"sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"bin\": {\n        \"tsc\": \"bin/tsc\",\n        \"tsserver\": \"bin/tsserver\"\n      },\n      \"engines\": {\n        \"node\": \">=14.17\"\n      }\n    },\n    \"node_modules/typescript-eslint\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz\",\n      \"integrity\": \"sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@typescript-eslint/eslint-plugin\": \"8.56.1\",\n        \"@typescript-eslint/parser\": \"8.56.1\",\n        \"@typescript-eslint/typescript-estree\": \"8.56.1\",\n        \"@typescript-eslint/utils\": \"8.56.1\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"eslint\": \"^8.57.0 || ^9.0.0 || ^10.0.0\",\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz\",\n      \"integrity\": \"sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@eslint-community/regexpp\": \"^4.12.2\",\n        \"@typescript-eslint/scope-manager\": \"8.56.1\",\n        \"@typescript-eslint/type-utils\": \"8.56.1\",\n        \"@typescript-eslint/utils\": \"8.56.1\",\n        \"@typescript-eslint/visitor-keys\": \"8.56.1\",\n        \"ignore\": \"^7.0.5\",\n        \"natural-compare\": \"^1.4.0\",\n        \"ts-api-utils\": \"^2.4.0\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"@typescript-eslint/parser\": \"^8.56.1\",\n        \"eslint\": \"^8.57.0 || ^9.0.0 || ^10.0.0\",\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz\",\n      \"integrity\": \"sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@typescript-eslint/scope-manager\": \"8.56.1\",\n        \"@typescript-eslint/types\": \"8.56.1\",\n        \"@typescript-eslint/typescript-estree\": \"8.56.1\",\n        \"@typescript-eslint/visitor-keys\": \"8.56.1\",\n        \"debug\": \"^4.4.3\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"eslint\": \"^8.57.0 || ^9.0.0 || ^10.0.0\",\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz\",\n      \"integrity\": \"sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@typescript-eslint/types\": \"8.56.1\",\n        \"@typescript-eslint/visitor-keys\": \"8.56.1\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz\",\n      \"integrity\": \"sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@typescript-eslint/types\": \"8.56.1\",\n        \"@typescript-eslint/typescript-estree\": \"8.56.1\",\n        \"@typescript-eslint/utils\": \"8.56.1\",\n        \"debug\": \"^4.4.3\",\n        \"ts-api-utils\": \"^2.4.0\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"eslint\": \"^8.57.0 || ^9.0.0 || ^10.0.0\",\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/types\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz\",\n      \"integrity\": \"sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz\",\n      \"integrity\": \"sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@typescript-eslint/project-service\": \"8.56.1\",\n        \"@typescript-eslint/tsconfig-utils\": \"8.56.1\",\n        \"@typescript-eslint/types\": \"8.56.1\",\n        \"@typescript-eslint/visitor-keys\": \"8.56.1\",\n        \"debug\": \"^4.4.3\",\n        \"minimatch\": \"^10.2.2\",\n        \"semver\": \"^7.7.3\",\n        \"tinyglobby\": \"^0.2.15\",\n        \"ts-api-utils\": \"^2.4.0\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/utils\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz\",\n      \"integrity\": \"sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@eslint-community/eslint-utils\": \"^4.9.1\",\n        \"@typescript-eslint/scope-manager\": \"8.56.1\",\n        \"@typescript-eslint/types\": \"8.56.1\",\n        \"@typescript-eslint/typescript-estree\": \"8.56.1\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      },\n      \"peerDependencies\": {\n        \"eslint\": \"^8.57.0 || ^9.0.0 || ^10.0.0\",\n        \"typescript\": \">=4.8.4 <6.0.0\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz\",\n      \"integrity\": \"sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@typescript-eslint/types\": \"8.56.1\",\n        \"eslint-visitor-keys\": \"^5.0.0\"\n      },\n      \"engines\": {\n        \"node\": \"^18.18.0 || ^20.9.0 || >=21.1.0\"\n      },\n      \"funding\": {\n        \"type\": \"opencollective\",\n        \"url\": \"https://opencollective.com/typescript-eslint\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/balanced-match\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz\",\n      \"integrity\": \"sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/brace-expansion\": {\n      \"version\": \"5.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz\",\n      \"integrity\": \"sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"balanced-match\": \"^4.0.2\"\n      },\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/eslint-visitor-keys\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz\",\n      \"integrity\": \"sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \"^20.19.0 || ^22.13.0 || >=24\"\n      },\n      \"funding\": {\n        \"url\": \"https://opencollective.com/eslint\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/ignore\": {\n      \"version\": \"7.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz\",\n      \"integrity\": \"sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">= 4\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/minimatch\": {\n      \"version\": \"10.2.4\",\n      \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz\",\n      \"integrity\": \"sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==\",\n      \"dev\": true,\n      \"license\": \"BlueOak-1.0.0\",\n      \"dependencies\": {\n        \"brace-expansion\": \"^5.0.2\"\n      },\n      \"engines\": {\n        \"node\": \"18 || 20 || >=22\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/isaacs\"\n      }\n    },\n    \"node_modules/typescript-eslint/node_modules/ts-api-utils\": {\n      \"version\": \"2.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz\",\n      \"integrity\": \"sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18.12\"\n      },\n      \"peerDependencies\": {\n        \"typescript\": \">=4.8.4\"\n      }\n    },\n    \"node_modules/undici-types\": {\n      \"version\": \"7.18.2\",\n      \"resolved\": \"https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz\",\n      \"integrity\": \"sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/unpipe\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz\",\n      \"integrity\": \"sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==\",\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/uri-js\": {\n      \"version\": \"4.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz\",\n      \"integrity\": \"sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==\",\n      \"dev\": true,\n      \"license\": \"BSD-2-Clause\",\n      \"dependencies\": {\n        \"punycode\": \"^2.1.0\"\n      }\n    },\n    \"node_modules/vary\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/vary/-/vary-1.1.2.tgz\",\n      \"integrity\": \"sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==\",\n      \"engines\": {\n        \"node\": \">= 0.8\"\n      }\n    },\n    \"node_modules/w3c-keyname\": {\n      \"version\": \"2.2.8\",\n      \"resolved\": \"https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz\",\n      \"integrity\": \"sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==\",\n      \"dev\": true\n    },\n    \"node_modules/webidl-conversions\": {\n      \"version\": \"7.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz\",\n      \"integrity\": \"sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==\",\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/whatwg-url\": {\n      \"version\": \"11.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz\",\n      \"integrity\": \"sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==\",\n      \"dependencies\": {\n        \"tr46\": \"^3.0.0\",\n        \"webidl-conversions\": \"^7.0.0\"\n      },\n      \"engines\": {\n        \"node\": \">=12\"\n      }\n    },\n    \"node_modules/which\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/which/-/which-2.0.2.tgz\",\n      \"integrity\": \"sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==\",\n      \"dev\": true,\n      \"license\": \"ISC\",\n      \"dependencies\": {\n        \"isexe\": \"^2.0.0\"\n      },\n      \"bin\": {\n        \"node-which\": \"bin/node-which\"\n      },\n      \"engines\": {\n        \"node\": \">= 8\"\n      }\n    },\n    \"node_modules/word-wrap\": {\n      \"version\": \"1.2.5\",\n      \"resolved\": \"https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz\",\n      \"integrity\": \"sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=0.10.0\"\n      }\n    },\n    \"node_modules/yaml\": {\n      \"version\": \"1.10.2\",\n      \"resolved\": \"https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz\",\n      \"integrity\": \"sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">= 6\"\n      }\n    },\n    \"node_modules/ylru\": {\n      \"version\": \"1.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz\",\n      \"integrity\": \"sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==\",\n      \"engines\": {\n        \"node\": \">= 4.0.0\"\n      }\n    },\n    \"node_modules/yocto-queue\": {\n      \"version\": \"0.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz\",\n      \"integrity\": \"sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==\",\n      \"dev\": true,\n      \"engines\": {\n        \"node\": \">=10\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/sponsors/sindresorhus\"\n      }\n    }\n  },\n  \"dependencies\": {\n    \"@aws-crypto/sha256-browser\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz\",\n      \"integrity\": \"sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-crypto/sha256-js\": \"^5.2.0\",\n        \"@aws-crypto/supports-web-crypto\": \"^5.2.0\",\n        \"@aws-crypto/util\": \"^5.2.0\",\n        \"@aws-sdk/types\": \"^3.222.0\",\n        \"@aws-sdk/util-locate-window\": \"^3.0.0\",\n        \"@smithy/util-utf8\": \"^2.0.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"dependencies\": {\n        \"@smithy/is-array-buffer\": {\n          \"version\": \"2.2.0\",\n          \"resolved\": \"https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz\",\n          \"integrity\": \"sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==\",\n          \"optional\": true,\n          \"requires\": {\n            \"tslib\": \"^2.6.2\"\n          }\n        },\n        \"@smithy/util-buffer-from\": {\n          \"version\": \"2.2.0\",\n          \"resolved\": \"https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz\",\n          \"integrity\": \"sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==\",\n          \"optional\": true,\n          \"requires\": {\n            \"@smithy/is-array-buffer\": \"^2.2.0\",\n            \"tslib\": \"^2.6.2\"\n          }\n        },\n        \"@smithy/util-utf8\": {\n          \"version\": \"2.3.0\",\n          \"resolved\": \"https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz\",\n          \"integrity\": \"sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==\",\n          \"optional\": true,\n          \"requires\": {\n            \"@smithy/util-buffer-from\": \"^2.2.0\",\n            \"tslib\": \"^2.6.2\"\n          }\n        }\n      }\n    },\n    \"@aws-crypto/sha256-js\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz\",\n      \"integrity\": \"sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-crypto/util\": \"^5.2.0\",\n        \"@aws-sdk/types\": \"^3.222.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-crypto/supports-web-crypto\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz\",\n      \"integrity\": \"sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-crypto/util\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz\",\n      \"integrity\": \"sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.222.0\",\n        \"@smithy/util-utf8\": \"^2.0.0\",\n        \"tslib\": \"^2.6.2\"\n      },\n      \"dependencies\": {\n        \"@smithy/is-array-buffer\": {\n          \"version\": \"2.2.0\",\n          \"resolved\": \"https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz\",\n          \"integrity\": \"sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==\",\n          \"optional\": true,\n          \"requires\": {\n            \"tslib\": \"^2.6.2\"\n          }\n        },\n        \"@smithy/util-buffer-from\": {\n          \"version\": \"2.2.0\",\n          \"resolved\": \"https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz\",\n          \"integrity\": \"sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==\",\n          \"optional\": true,\n          \"requires\": {\n            \"@smithy/is-array-buffer\": \"^2.2.0\",\n            \"tslib\": \"^2.6.2\"\n          }\n        },\n        \"@smithy/util-utf8\": {\n          \"version\": \"2.3.0\",\n          \"resolved\": \"https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz\",\n          \"integrity\": \"sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==\",\n          \"optional\": true,\n          \"requires\": {\n            \"@smithy/util-buffer-from\": \"^2.2.0\",\n            \"tslib\": \"^2.6.2\"\n          }\n        }\n      }\n    },\n    \"@aws-sdk/client-cognito-identity\": {\n      \"version\": \"3.1008.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1008.0.tgz\",\n      \"integrity\": \"sha512-zzHnrTImR1JJ/Sq90y35UiFiriwge6W8qZQxIBJCgAMwEGkQAqHEAc3d6ptLmwdntcid3dx7wvauOXbpiMVbAQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-crypto/sha256-browser\": \"5.2.0\",\n        \"@aws-crypto/sha256-js\": \"5.2.0\",\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/credential-provider-node\": \"^3.972.20\",\n        \"@aws-sdk/middleware-host-header\": \"^3.972.7\",\n        \"@aws-sdk/middleware-logger\": \"^3.972.7\",\n        \"@aws-sdk/middleware-recursion-detection\": \"^3.972.7\",\n        \"@aws-sdk/middleware-user-agent\": \"^3.972.20\",\n        \"@aws-sdk/region-config-resolver\": \"^3.972.7\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/util-endpoints\": \"^3.996.4\",\n        \"@aws-sdk/util-user-agent-browser\": \"^3.972.7\",\n        \"@aws-sdk/util-user-agent-node\": \"^3.973.6\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/fetch-http-handler\": \"^5.3.13\",\n        \"@smithy/hash-node\": \"^4.2.11\",\n        \"@smithy/invalid-dependency\": \"^4.2.11\",\n        \"@smithy/middleware-content-length\": \"^4.2.11\",\n        \"@smithy/middleware-endpoint\": \"^4.4.23\",\n        \"@smithy/middleware-retry\": \"^4.4.40\",\n        \"@smithy/middleware-serde\": \"^4.2.12\",\n        \"@smithy/middleware-stack\": \"^4.2.11\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/node-http-handler\": \"^4.4.14\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/url-parser\": \"^4.2.11\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-body-length-browser\": \"^4.2.2\",\n        \"@smithy/util-body-length-node\": \"^4.2.3\",\n        \"@smithy/util-defaults-mode-browser\": \"^4.3.39\",\n        \"@smithy/util-defaults-mode-node\": \"^4.2.42\",\n        \"@smithy/util-endpoints\": \"^3.3.2\",\n        \"@smithy/util-middleware\": \"^4.2.11\",\n        \"@smithy/util-retry\": \"^4.2.11\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/core\": {\n      \"version\": \"3.973.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz\",\n      \"integrity\": \"sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/xml-builder\": \"^3.972.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/signature-v4\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-middleware\": \"^4.2.11\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-cognito-identity\": {\n      \"version\": \"3.972.12\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.12.tgz\",\n      \"integrity\": \"sha512-0R7EKJBd19VGoYMrp7ozikwRh6KpapIO3T/Vf9tMrAVxrUNd5V+A6V1gxypY7iJv9GwVR1ZWL/nFt/m0KvcjIQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-env\": {\n      \"version\": \"3.972.17\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz\",\n      \"integrity\": \"sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-http\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz\",\n      \"integrity\": \"sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/fetch-http-handler\": \"^5.3.13\",\n        \"@smithy/node-http-handler\": \"^4.4.14\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-stream\": \"^4.5.17\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-ini\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.19.tgz\",\n      \"integrity\": \"sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/credential-provider-env\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-http\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-login\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-process\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-sso\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-web-identity\": \"^3.972.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/credential-provider-imds\": \"^4.2.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-login\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.19.tgz\",\n      \"integrity\": \"sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-node\": {\n      \"version\": \"3.972.20\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.20.tgz\",\n      \"integrity\": \"sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/credential-provider-env\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-http\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-ini\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-process\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-sso\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-web-identity\": \"^3.972.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/credential-provider-imds\": \"^4.2.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-process\": {\n      \"version\": \"3.972.17\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz\",\n      \"integrity\": \"sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-sso\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.19.tgz\",\n      \"integrity\": \"sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/token-providers\": \"3.1008.0\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-provider-web-identity\": {\n      \"version\": \"3.972.19\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.19.tgz\",\n      \"integrity\": \"sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/credential-providers\": {\n      \"version\": \"3.1008.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1008.0.tgz\",\n      \"integrity\": \"sha512-JPjsKAYpuaDwmeE2WvrrfTb27FYa6kIe0gj1JCazHWGteQ6LDycBddsDsRSgq2MfqAqdcHnrgnfGzY1+j8AxoQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/client-cognito-identity\": \"3.1008.0\",\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/credential-provider-cognito-identity\": \"^3.972.12\",\n        \"@aws-sdk/credential-provider-env\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-http\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-ini\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-login\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-node\": \"^3.972.20\",\n        \"@aws-sdk/credential-provider-process\": \"^3.972.17\",\n        \"@aws-sdk/credential-provider-sso\": \"^3.972.19\",\n        \"@aws-sdk/credential-provider-web-identity\": \"^3.972.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/credential-provider-imds\": \"^4.2.11\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/middleware-host-header\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz\",\n      \"integrity\": \"sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/middleware-logger\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz\",\n      \"integrity\": \"sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/middleware-recursion-detection\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz\",\n      \"integrity\": \"sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws/lambda-invoke-store\": \"^0.2.2\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/middleware-user-agent\": {\n      \"version\": \"3.972.20\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz\",\n      \"integrity\": \"sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/util-endpoints\": \"^3.996.4\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-retry\": \"^4.2.11\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/nested-clients\": {\n      \"version\": \"3.996.9\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.9.tgz\",\n      \"integrity\": \"sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-crypto/sha256-browser\": \"5.2.0\",\n        \"@aws-crypto/sha256-js\": \"5.2.0\",\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/middleware-host-header\": \"^3.972.7\",\n        \"@aws-sdk/middleware-logger\": \"^3.972.7\",\n        \"@aws-sdk/middleware-recursion-detection\": \"^3.972.7\",\n        \"@aws-sdk/middleware-user-agent\": \"^3.972.20\",\n        \"@aws-sdk/region-config-resolver\": \"^3.972.7\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@aws-sdk/util-endpoints\": \"^3.996.4\",\n        \"@aws-sdk/util-user-agent-browser\": \"^3.972.7\",\n        \"@aws-sdk/util-user-agent-node\": \"^3.973.6\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/core\": \"^3.23.9\",\n        \"@smithy/fetch-http-handler\": \"^5.3.13\",\n        \"@smithy/hash-node\": \"^4.2.11\",\n        \"@smithy/invalid-dependency\": \"^4.2.11\",\n        \"@smithy/middleware-content-length\": \"^4.2.11\",\n        \"@smithy/middleware-endpoint\": \"^4.4.23\",\n        \"@smithy/middleware-retry\": \"^4.4.40\",\n        \"@smithy/middleware-serde\": \"^4.2.12\",\n        \"@smithy/middleware-stack\": \"^4.2.11\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/node-http-handler\": \"^4.4.14\",\n        \"@smithy/protocol-http\": \"^5.3.11\",\n        \"@smithy/smithy-client\": \"^4.12.3\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/url-parser\": \"^4.2.11\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-body-length-browser\": \"^4.2.2\",\n        \"@smithy/util-body-length-node\": \"^4.2.3\",\n        \"@smithy/util-defaults-mode-browser\": \"^4.3.39\",\n        \"@smithy/util-defaults-mode-node\": \"^4.2.42\",\n        \"@smithy/util-endpoints\": \"^3.3.2\",\n        \"@smithy/util-middleware\": \"^4.2.11\",\n        \"@smithy/util-retry\": \"^4.2.11\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/region-config-resolver\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz\",\n      \"integrity\": \"sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/config-resolver\": \"^4.4.10\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/token-providers\": {\n      \"version\": \"3.1008.0\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1008.0.tgz\",\n      \"integrity\": \"sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/core\": \"^3.973.19\",\n        \"@aws-sdk/nested-clients\": \"^3.996.9\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/property-provider\": \"^4.2.11\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.6\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/types\": {\n      \"version\": \"3.973.5\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz\",\n      \"integrity\": \"sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/util-endpoints\": {\n      \"version\": \"3.996.4\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz\",\n      \"integrity\": \"sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/url-parser\": \"^4.2.11\",\n        \"@smithy/util-endpoints\": \"^3.3.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/util-locate-window\": {\n      \"version\": \"3.965.5\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz\",\n      \"integrity\": \"sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/util-user-agent-browser\": {\n      \"version\": \"3.972.7\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz\",\n      \"integrity\": \"sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"bowser\": \"^2.11.0\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/util-user-agent-node\": {\n      \"version\": \"3.973.6\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.6.tgz\",\n      \"integrity\": \"sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@aws-sdk/middleware-user-agent\": \"^3.972.20\",\n        \"@aws-sdk/types\": \"^3.973.5\",\n        \"@smithy/node-config-provider\": \"^4.3.11\",\n        \"@smithy/types\": \"^4.13.0\",\n        \"@smithy/util-config-provider\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws-sdk/xml-builder\": {\n      \"version\": \"3.972.10\",\n      \"resolved\": \"https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz\",\n      \"integrity\": \"sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.0\",\n        \"fast-xml-parser\": \"5.4.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@aws/lambda-invoke-store\": {\n      \"version\": \"0.2.4\",\n      \"resolved\": \"https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz\",\n      \"integrity\": \"sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==\",\n      \"optional\": true\n    },\n    \"@breejs/later\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@breejs/later/-/later-4.2.0.tgz\",\n      \"integrity\": \"sha512-EVMD0SgJtOuFeg0lAVbCwa+qeTKILb87jqvLyUtQswGD9+ce2nB52Y5zbTF1Hc0MDFfbydcMcxb47jSdhikVHA==\"\n    },\n    \"@codemirror/autocomplete\": {\n      \"version\": \"6.16.3\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.3.tgz\",\n      \"integrity\": \"sha512-Vl/tIeRVVUCRDuOG48lttBasNQu8usGgXQawBXI7WJAiUDSFOfzflmEsZFZo48mAvAaa4FZ/4/yLLxFtdJaKYA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@codemirror/language\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.17.0\",\n        \"@lezer/common\": \"^1.0.0\"\n      }\n    },\n    \"@codemirror/commands\": {\n      \"version\": \"6.10.2\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz\",\n      \"integrity\": \"sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@codemirror/language\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.4.0\",\n        \"@codemirror/view\": \"^6.27.0\",\n        \"@lezer/common\": \"^1.1.0\"\n      }\n    },\n    \"@codemirror/lang-javascript\": {\n      \"version\": \"6.2.5\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz\",\n      \"integrity\": \"sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@codemirror/autocomplete\": \"^6.0.0\",\n        \"@codemirror/language\": \"^6.6.0\",\n        \"@codemirror/lint\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.17.0\",\n        \"@lezer/common\": \"^1.0.0\",\n        \"@lezer/javascript\": \"^1.0.0\"\n      }\n    },\n    \"@codemirror/lang-yaml\": {\n      \"version\": \"6.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz\",\n      \"integrity\": \"sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@codemirror/autocomplete\": \"^6.0.0\",\n        \"@codemirror/language\": \"^6.0.0\",\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@lezer/common\": \"^1.2.0\",\n        \"@lezer/highlight\": \"^1.2.0\",\n        \"@lezer/lr\": \"^1.0.0\",\n        \"@lezer/yaml\": \"^1.0.0\"\n      }\n    },\n    \"@codemirror/language\": {\n      \"version\": \"6.12.2\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz\",\n      \"integrity\": \"sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.23.0\",\n        \"@lezer/common\": \"^1.5.0\",\n        \"@lezer/highlight\": \"^1.0.0\",\n        \"@lezer/lr\": \"^1.0.0\",\n        \"style-mod\": \"^4.0.0\"\n      }\n    },\n    \"@codemirror/lint\": {\n      \"version\": \"6.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz\",\n      \"integrity\": \"sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@codemirror/state\": \"^6.0.0\",\n        \"@codemirror/view\": \"^6.0.0\",\n        \"crelt\": \"^1.0.5\"\n      }\n    },\n    \"@codemirror/state\": {\n      \"version\": \"6.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz\",\n      \"integrity\": \"sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@marijn/find-cluster-break\": \"^1.0.0\"\n      }\n    },\n    \"@codemirror/view\": {\n      \"version\": \"6.39.16\",\n      \"resolved\": \"https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz\",\n      \"integrity\": \"sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@codemirror/state\": \"^6.5.0\",\n        \"crelt\": \"^1.0.6\",\n        \"style-mod\": \"^4.1.0\",\n        \"w3c-keyname\": \"^2.2.4\"\n      }\n    },\n    \"@esbuild/aix-ppc64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz\",\n      \"integrity\": \"sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/android-arm\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz\",\n      \"integrity\": \"sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/android-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/android-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/darwin-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/darwin-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/freebsd-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/freebsd-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-arm\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz\",\n      \"integrity\": \"sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-ia32\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz\",\n      \"integrity\": \"sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-loong64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz\",\n      \"integrity\": \"sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-mips64el\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz\",\n      \"integrity\": \"sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-ppc64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz\",\n      \"integrity\": \"sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-riscv64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz\",\n      \"integrity\": \"sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-s390x\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz\",\n      \"integrity\": \"sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/linux-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/netbsd-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/netbsd-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/openbsd-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/openbsd-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/openharmony-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/sunos-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/win32-arm64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz\",\n      \"integrity\": \"sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/win32-ia32\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz\",\n      \"integrity\": \"sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@esbuild/win32-x64\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz\",\n      \"integrity\": \"sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@eslint-community/eslint-utils\": {\n      \"version\": \"4.9.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz\",\n      \"integrity\": \"sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"eslint-visitor-keys\": \"^3.4.3\"\n      }\n    },\n    \"@eslint-community/regexpp\": {\n      \"version\": \"4.12.2\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz\",\n      \"integrity\": \"sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==\",\n      \"dev\": true\n    },\n    \"@eslint/config-array\": {\n      \"version\": \"0.23.3\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz\",\n      \"integrity\": \"sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@eslint/object-schema\": \"^3.0.3\",\n        \"debug\": \"^4.3.1\",\n        \"minimatch\": \"^10.2.4\"\n      },\n      \"dependencies\": {\n        \"balanced-match\": {\n          \"version\": \"4.0.4\",\n          \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz\",\n          \"integrity\": \"sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==\",\n          \"dev\": true\n        },\n        \"brace-expansion\": {\n          \"version\": \"5.0.4\",\n          \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz\",\n          \"integrity\": \"sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"balanced-match\": \"^4.0.2\"\n          }\n        },\n        \"minimatch\": {\n          \"version\": \"10.2.4\",\n          \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz\",\n          \"integrity\": \"sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"brace-expansion\": \"^5.0.2\"\n          }\n        }\n      }\n    },\n    \"@eslint/config-helpers\": {\n      \"version\": \"0.5.3\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz\",\n      \"integrity\": \"sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@eslint/core\": \"^1.1.1\"\n      }\n    },\n    \"@eslint/core\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz\",\n      \"integrity\": \"sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/json-schema\": \"^7.0.15\"\n      }\n    },\n    \"@eslint/js\": {\n      \"version\": \"10.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz\",\n      \"integrity\": \"sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==\",\n      \"dev\": true,\n      \"requires\": {}\n    },\n    \"@eslint/object-schema\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz\",\n      \"integrity\": \"sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==\",\n      \"dev\": true\n    },\n    \"@eslint/plugin-kit\": {\n      \"version\": \"0.6.1\",\n      \"resolved\": \"https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz\",\n      \"integrity\": \"sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@humanwhocodes/object-schema\": \"^2.0.3\",\n        \"debug\": \"^4.3.1\",\n        \"minimatch\": \"^3.0.5\"\n      },\n      \"dependencies\": {\n        \"brace-expansion\": {\n          \"version\": \"1.1.12\",\n          \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz\",\n          \"integrity\": \"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"balanced-match\": \"^1.0.0\",\n            \"concat-map\": \"0.0.1\"\n          }\n        },\n        \"minimatch\": {\n          \"version\": \"3.1.5\",\n          \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz\",\n          \"integrity\": \"sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==\",\n          \"dev\": true,\n          \"requires\": {\n            \"brace-expansion\": \"^1.1.7\"\n          }\n        }\n      }\n    },\n    \"@humanfs/core\": {\n      \"version\": \"0.19.1\",\n      \"resolved\": \"https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz\",\n      \"integrity\": \"sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==\",\n      \"dev\": true\n    },\n    \"@humanfs/node\": {\n      \"version\": \"0.16.7\",\n      \"resolved\": \"https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz\",\n      \"integrity\": \"sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@humanfs/core\": \"^0.19.1\",\n        \"@humanwhocodes/retry\": \"^0.4.0\"\n      }\n    },\n    \"@humanwhocodes/module-importer\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz\",\n      \"integrity\": \"sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==\",\n      \"dev\": true\n    },\n    \"@humanwhocodes/object-schema\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz\",\n      \"integrity\": \"sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==\",\n      \"dev\": true\n    },\n    \"@humanwhocodes/retry\": {\n      \"version\": \"0.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz\",\n      \"integrity\": \"sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==\",\n      \"dev\": true\n    },\n    \"@jridgewell/gen-mapping\": {\n      \"version\": \"0.3.13\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz\",\n      \"integrity\": \"sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@jridgewell/sourcemap-codec\": \"^1.5.0\",\n        \"@jridgewell/trace-mapping\": \"^0.3.24\"\n      }\n    },\n    \"@jridgewell/remapping\": {\n      \"version\": \"2.3.5\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz\",\n      \"integrity\": \"sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@jridgewell/gen-mapping\": \"^0.3.5\",\n        \"@jridgewell/trace-mapping\": \"^0.3.24\"\n      }\n    },\n    \"@jridgewell/resolve-uri\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz\",\n      \"integrity\": \"sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==\",\n      \"dev\": true\n    },\n    \"@jridgewell/sourcemap-codec\": {\n      \"version\": \"1.5.5\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz\",\n      \"integrity\": \"sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==\",\n      \"dev\": true\n    },\n    \"@jridgewell/trace-mapping\": {\n      \"version\": \"0.3.31\",\n      \"resolved\": \"https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz\",\n      \"integrity\": \"sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@jridgewell/resolve-uri\": \"^3.1.0\",\n        \"@jridgewell/sourcemap-codec\": \"^1.4.14\"\n      }\n    },\n    \"@koa/bodyparser\": {\n      \"version\": \"5.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@koa/bodyparser/-/bodyparser-5.1.2.tgz\",\n      \"integrity\": \"sha512-eGJm9/66iUX+LUH03Cz0e94unbSKrmSPCick4MO5UorAAomcjC5Kl+SkoZ6CSyPew3neMYjj7n+djnlGYBSJAg==\",\n      \"requires\": {\n        \"co-body\": \"^6.1.0\",\n        \"lodash.merge\": \"^4.6.2\",\n        \"type-is\": \"^1.6.18\"\n      }\n    },\n    \"@koa/router\": {\n      \"version\": \"13.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@koa/router/-/router-13.1.1.tgz\",\n      \"integrity\": \"sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw==\",\n      \"requires\": {\n        \"debug\": \"^4.4.1\",\n        \"http-errors\": \"^2.0.0\",\n        \"koa-compose\": \"^4.1.0\",\n        \"path-to-regexp\": \"^6.3.0\"\n      }\n    },\n    \"@lezer/common\": {\n      \"version\": \"1.5.1\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz\",\n      \"integrity\": \"sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==\",\n      \"dev\": true\n    },\n    \"@lezer/highlight\": {\n      \"version\": \"1.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz\",\n      \"integrity\": \"sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@lezer/common\": \"^1.3.0\"\n      }\n    },\n    \"@lezer/javascript\": {\n      \"version\": \"1.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz\",\n      \"integrity\": \"sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@lezer/common\": \"^1.2.0\",\n        \"@lezer/highlight\": \"^1.1.3\",\n        \"@lezer/lr\": \"^1.3.0\"\n      }\n    },\n    \"@lezer/lr\": {\n      \"version\": \"1.4.8\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz\",\n      \"integrity\": \"sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@lezer/common\": \"^1.0.0\"\n      }\n    },\n    \"@lezer/yaml\": {\n      \"version\": \"1.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz\",\n      \"integrity\": \"sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@lezer/common\": \"^1.2.0\",\n        \"@lezer/highlight\": \"^1.0.0\",\n        \"@lezer/lr\": \"^1.4.0\"\n      }\n    },\n    \"@marijn/find-cluster-break\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz\",\n      \"integrity\": \"sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==\",\n      \"dev\": true\n    },\n    \"@mongodb-js/saslprep\": {\n      \"version\": \"1.4.6\",\n      \"resolved\": \"https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz\",\n      \"integrity\": \"sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==\",\n      \"optional\": true,\n      \"requires\": {\n        \"sparse-bitfield\": \"^3.0.3\"\n      }\n    },\n    \"@parcel/watcher\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz\",\n      \"integrity\": \"sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@parcel/watcher-android-arm64\": \"2.5.6\",\n        \"@parcel/watcher-darwin-arm64\": \"2.5.6\",\n        \"@parcel/watcher-darwin-x64\": \"2.5.6\",\n        \"@parcel/watcher-freebsd-x64\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm-glibc\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm-musl\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm64-glibc\": \"2.5.6\",\n        \"@parcel/watcher-linux-arm64-musl\": \"2.5.6\",\n        \"@parcel/watcher-linux-x64-glibc\": \"2.5.6\",\n        \"@parcel/watcher-linux-x64-musl\": \"2.5.6\",\n        \"@parcel/watcher-win32-arm64\": \"2.5.6\",\n        \"@parcel/watcher-win32-ia32\": \"2.5.6\",\n        \"@parcel/watcher-win32-x64\": \"2.5.6\",\n        \"detect-libc\": \"^2.0.3\",\n        \"is-glob\": \"^4.0.3\",\n        \"node-addon-api\": \"^7.0.0\",\n        \"picomatch\": \"^4.0.3\"\n      },\n      \"dependencies\": {\n        \"picomatch\": {\n          \"version\": \"4.0.3\",\n          \"resolved\": \"https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz\",\n          \"integrity\": \"sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==\",\n          \"dev\": true\n        }\n      }\n    },\n    \"@parcel/watcher-android-arm64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz\",\n      \"integrity\": \"sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-darwin-arm64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz\",\n      \"integrity\": \"sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-darwin-x64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz\",\n      \"integrity\": \"sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-freebsd-x64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz\",\n      \"integrity\": \"sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-linux-arm-glibc\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz\",\n      \"integrity\": \"sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-linux-arm-musl\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz\",\n      \"integrity\": \"sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-linux-arm64-glibc\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz\",\n      \"integrity\": \"sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-linux-arm64-musl\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz\",\n      \"integrity\": \"sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-linux-x64-glibc\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz\",\n      \"integrity\": \"sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-linux-x64-musl\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz\",\n      \"integrity\": \"sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-win32-arm64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz\",\n      \"integrity\": \"sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-win32-ia32\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz\",\n      \"integrity\": \"sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@parcel/watcher-win32-x64\": {\n      \"version\": \"2.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz\",\n      \"integrity\": \"sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@smithy/abort-controller\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz\",\n      \"integrity\": \"sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/config-resolver\": {\n      \"version\": \"4.4.11\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz\",\n      \"integrity\": \"sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-config-provider\": \"^4.2.2\",\n        \"@smithy/util-endpoints\": \"^3.3.3\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/core\": {\n      \"version\": \"3.23.11\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/core/-/core-3.23.11.tgz\",\n      \"integrity\": \"sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/url-parser\": \"^4.2.12\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-body-length-browser\": \"^4.2.2\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"@smithy/util-stream\": \"^4.5.19\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"@smithy/uuid\": \"^1.1.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/credential-provider-imds\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz\",\n      \"integrity\": \"sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/url-parser\": \"^4.2.12\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/fetch-http-handler\": {\n      \"version\": \"5.3.15\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz\",\n      \"integrity\": \"sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/querystring-builder\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/hash-node\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz\",\n      \"integrity\": \"sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/invalid-dependency\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz\",\n      \"integrity\": \"sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/is-array-buffer\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz\",\n      \"integrity\": \"sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/middleware-content-length\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz\",\n      \"integrity\": \"sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/middleware-endpoint\": {\n      \"version\": \"4.4.25\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.25.tgz\",\n      \"integrity\": \"sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/core\": \"^3.23.11\",\n        \"@smithy/middleware-serde\": \"^4.2.14\",\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.7\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/url-parser\": \"^4.2.12\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/middleware-retry\": {\n      \"version\": \"4.4.42\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.42.tgz\",\n      \"integrity\": \"sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/service-error-classification\": \"^4.2.12\",\n        \"@smithy/smithy-client\": \"^4.12.5\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"@smithy/util-retry\": \"^4.2.12\",\n        \"@smithy/uuid\": \"^1.1.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/middleware-serde\": {\n      \"version\": \"4.2.14\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.14.tgz\",\n      \"integrity\": \"sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/core\": \"^3.23.11\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/middleware-stack\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz\",\n      \"integrity\": \"sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/node-config-provider\": {\n      \"version\": \"4.3.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz\",\n      \"integrity\": \"sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/shared-ini-file-loader\": \"^4.4.7\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/node-http-handler\": {\n      \"version\": \"4.4.16\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.16.tgz\",\n      \"integrity\": \"sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/abort-controller\": \"^4.2.12\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/querystring-builder\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/property-provider\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz\",\n      \"integrity\": \"sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/protocol-http\": {\n      \"version\": \"5.3.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz\",\n      \"integrity\": \"sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/querystring-builder\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz\",\n      \"integrity\": \"sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-uri-escape\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/querystring-parser\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz\",\n      \"integrity\": \"sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/service-error-classification\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz\",\n      \"integrity\": \"sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\"\n      }\n    },\n    \"@smithy/shared-ini-file-loader\": {\n      \"version\": \"4.4.7\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz\",\n      \"integrity\": \"sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/signature-v4\": {\n      \"version\": \"5.3.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz\",\n      \"integrity\": \"sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/is-array-buffer\": \"^4.2.2\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-hex-encoding\": \"^4.2.2\",\n        \"@smithy/util-middleware\": \"^4.2.12\",\n        \"@smithy/util-uri-escape\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/smithy-client\": {\n      \"version\": \"4.12.5\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.5.tgz\",\n      \"integrity\": \"sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/core\": \"^3.23.11\",\n        \"@smithy/middleware-endpoint\": \"^4.4.25\",\n        \"@smithy/middleware-stack\": \"^4.2.12\",\n        \"@smithy/protocol-http\": \"^5.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-stream\": \"^4.5.19\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/types\": {\n      \"version\": \"4.13.1\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz\",\n      \"integrity\": \"sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/url-parser\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz\",\n      \"integrity\": \"sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/querystring-parser\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-base64\": {\n      \"version\": \"4.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz\",\n      \"integrity\": \"sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-body-length-browser\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz\",\n      \"integrity\": \"sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-body-length-node\": {\n      \"version\": \"4.2.3\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz\",\n      \"integrity\": \"sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-buffer-from\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz\",\n      \"integrity\": \"sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/is-array-buffer\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-config-provider\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz\",\n      \"integrity\": \"sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-defaults-mode-browser\": {\n      \"version\": \"4.3.41\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.41.tgz\",\n      \"integrity\": \"sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/smithy-client\": \"^4.12.5\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-defaults-mode-node\": {\n      \"version\": \"4.2.44\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.44.tgz\",\n      \"integrity\": \"sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/config-resolver\": \"^4.4.11\",\n        \"@smithy/credential-provider-imds\": \"^4.2.12\",\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/property-provider\": \"^4.2.12\",\n        \"@smithy/smithy-client\": \"^4.12.5\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-endpoints\": {\n      \"version\": \"3.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz\",\n      \"integrity\": \"sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/node-config-provider\": \"^4.3.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-hex-encoding\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz\",\n      \"integrity\": \"sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-middleware\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz\",\n      \"integrity\": \"sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-retry\": {\n      \"version\": \"4.2.12\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz\",\n      \"integrity\": \"sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/service-error-classification\": \"^4.2.12\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-stream\": {\n      \"version\": \"4.5.19\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.19.tgz\",\n      \"integrity\": \"sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/fetch-http-handler\": \"^5.3.15\",\n        \"@smithy/node-http-handler\": \"^4.4.16\",\n        \"@smithy/types\": \"^4.13.1\",\n        \"@smithy/util-base64\": \"^4.3.2\",\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"@smithy/util-hex-encoding\": \"^4.2.2\",\n        \"@smithy/util-utf8\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-uri-escape\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz\",\n      \"integrity\": \"sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/util-utf8\": {\n      \"version\": \"4.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz\",\n      \"integrity\": \"sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==\",\n      \"optional\": true,\n      \"requires\": {\n        \"@smithy/util-buffer-from\": \"^4.2.2\",\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@smithy/uuid\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz\",\n      \"integrity\": \"sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==\",\n      \"optional\": true,\n      \"requires\": {\n        \"tslib\": \"^2.6.2\"\n      }\n    },\n    \"@tailwindcss/cli\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz\",\n      \"integrity\": \"sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@parcel/watcher\": \"^2.5.1\",\n        \"@tailwindcss/node\": \"4.2.1\",\n        \"@tailwindcss/oxide\": \"4.2.1\",\n        \"enhanced-resolve\": \"^5.19.0\",\n        \"mri\": \"^1.2.0\",\n        \"picocolors\": \"^1.1.1\",\n        \"tailwindcss\": \"4.2.1\"\n      }\n    },\n    \"@tailwindcss/forms\": {\n      \"version\": \"0.5.11\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz\",\n      \"integrity\": \"sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"mini-svg-data-uri\": \"^1.2.3\"\n      }\n    },\n    \"@tailwindcss/node\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz\",\n      \"integrity\": \"sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@jridgewell/remapping\": \"^2.3.5\",\n        \"enhanced-resolve\": \"^5.19.0\",\n        \"jiti\": \"^2.6.1\",\n        \"lightningcss\": \"1.31.1\",\n        \"magic-string\": \"^0.30.21\",\n        \"source-map-js\": \"^1.2.1\",\n        \"tailwindcss\": \"4.2.1\"\n      }\n    },\n    \"@tailwindcss/oxide\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz\",\n      \"integrity\": \"sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@tailwindcss/oxide-android-arm64\": \"4.2.1\",\n        \"@tailwindcss/oxide-darwin-arm64\": \"4.2.1\",\n        \"@tailwindcss/oxide-darwin-x64\": \"4.2.1\",\n        \"@tailwindcss/oxide-freebsd-x64\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-arm-gnueabihf\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-arm64-gnu\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-arm64-musl\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-x64-gnu\": \"4.2.1\",\n        \"@tailwindcss/oxide-linux-x64-musl\": \"4.2.1\",\n        \"@tailwindcss/oxide-wasm32-wasi\": \"4.2.1\",\n        \"@tailwindcss/oxide-win32-arm64-msvc\": \"4.2.1\",\n        \"@tailwindcss/oxide-win32-x64-msvc\": \"4.2.1\"\n      }\n    },\n    \"@tailwindcss/oxide-android-arm64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz\",\n      \"integrity\": \"sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-darwin-arm64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz\",\n      \"integrity\": \"sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-darwin-x64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz\",\n      \"integrity\": \"sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-freebsd-x64\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz\",\n      \"integrity\": \"sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-linux-arm-gnueabihf\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz\",\n      \"integrity\": \"sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-linux-arm64-gnu\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz\",\n      \"integrity\": \"sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-linux-arm64-musl\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz\",\n      \"integrity\": \"sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-linux-x64-gnu\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz\",\n      \"integrity\": \"sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-linux-x64-musl\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz\",\n      \"integrity\": \"sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-wasm32-wasi\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz\",\n      \"integrity\": \"sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==\",\n      \"dev\": true,\n      \"optional\": true,\n      \"requires\": {\n        \"@emnapi/core\": \"^1.8.1\",\n        \"@emnapi/runtime\": \"^1.8.1\",\n        \"@emnapi/wasi-threads\": \"^1.1.0\",\n        \"@napi-rs/wasm-runtime\": \"^1.1.1\",\n        \"@tybys/wasm-util\": \"^0.10.1\",\n        \"tslib\": \"^2.8.1\"\n      }\n    },\n    \"@tailwindcss/oxide-win32-arm64-msvc\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz\",\n      \"integrity\": \"sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@tailwindcss/oxide-win32-x64-msvc\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz\",\n      \"integrity\": \"sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"@types/accepts\": {\n      \"version\": \"1.3.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz\",\n      \"integrity\": \"sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/body-parser\": {\n      \"version\": \"1.19.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz\",\n      \"integrity\": \"sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/connect\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/connect\": {\n      \"version\": \"3.4.38\",\n      \"resolved\": \"https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz\",\n      \"integrity\": \"sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/content-disposition\": {\n      \"version\": \"0.5.9\",\n      \"resolved\": \"https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz\",\n      \"integrity\": \"sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==\",\n      \"dev\": true\n    },\n    \"@types/cookies\": {\n      \"version\": \"0.9.2\",\n      \"resolved\": \"https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz\",\n      \"integrity\": \"sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/connect\": \"*\",\n        \"@types/express\": \"*\",\n        \"@types/keygrip\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/esrecurse\": {\n      \"version\": \"4.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz\",\n      \"integrity\": \"sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==\",\n      \"dev\": true\n    },\n    \"@types/estree\": {\n      \"version\": \"1.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz\",\n      \"integrity\": \"sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==\",\n      \"dev\": true\n    },\n    \"@types/express\": {\n      \"version\": \"5.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz\",\n      \"integrity\": \"sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/body-parser\": \"*\",\n        \"@types/express-serve-static-core\": \"^5.0.0\",\n        \"@types/serve-static\": \"^2\"\n      }\n    },\n    \"@types/express-serve-static-core\": {\n      \"version\": \"5.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz\",\n      \"integrity\": \"sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/node\": \"*\",\n        \"@types/qs\": \"*\",\n        \"@types/range-parser\": \"*\",\n        \"@types/send\": \"*\"\n      }\n    },\n    \"@types/http-assert\": {\n      \"version\": \"1.5.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz\",\n      \"integrity\": \"sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==\",\n      \"dev\": true\n    },\n    \"@types/http-errors\": {\n      \"version\": \"2.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz\",\n      \"integrity\": \"sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==\",\n      \"dev\": true\n    },\n    \"@types/json-schema\": {\n      \"version\": \"7.0.15\",\n      \"resolved\": \"https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz\",\n      \"integrity\": \"sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==\",\n      \"dev\": true\n    },\n    \"@types/jsonwebtoken\": {\n      \"version\": \"9.0.10\",\n      \"resolved\": \"https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz\",\n      \"integrity\": \"sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/ms\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/keygrip\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz\",\n      \"integrity\": \"sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==\",\n      \"dev\": true\n    },\n    \"@types/koa\": {\n      \"version\": \"2.15.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz\",\n      \"integrity\": \"sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/accepts\": \"*\",\n        \"@types/content-disposition\": \"*\",\n        \"@types/cookies\": \"*\",\n        \"@types/http-assert\": \"*\",\n        \"@types/http-errors\": \"*\",\n        \"@types/keygrip\": \"*\",\n        \"@types/koa-compose\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/koa-compose\": {\n      \"version\": \"3.2.9\",\n      \"resolved\": \"https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz\",\n      \"integrity\": \"sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/koa\": \"*\"\n      }\n    },\n    \"@types/koa-compress\": {\n      \"version\": \"4.0.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/koa-compress/-/koa-compress-4.0.7.tgz\",\n      \"integrity\": \"sha512-NqP9qCBfXCu2+RYkGzEENBkqXWExOPeBEsvj3F0xtVxKDwwdfRRtVdpxJeTRAq2Ml3qlUnDbK8bKHWwe6V1kkg==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/koa\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/mithril\": {\n      \"version\": \"2.2.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/mithril/-/mithril-2.2.7.tgz\",\n      \"integrity\": \"sha512-uetxoYizBMHPELl6DSZUfO6Q/aOm+h0NUCv9bVAX2iAxfrdBSOvU9KKFl+McTtxR13F+BReYLY814pJsZvnSxg==\",\n      \"dev\": true\n    },\n    \"@types/ms\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz\",\n      \"integrity\": \"sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==\",\n      \"dev\": true\n    },\n    \"@types/node\": {\n      \"version\": \"25.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz\",\n      \"integrity\": \"sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==\",\n      \"requires\": {\n        \"undici-types\": \"~7.18.0\"\n      }\n    },\n    \"@types/qs\": {\n      \"version\": \"6.15.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz\",\n      \"integrity\": \"sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==\",\n      \"dev\": true\n    },\n    \"@types/range-parser\": {\n      \"version\": \"1.2.7\",\n      \"resolved\": \"https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz\",\n      \"integrity\": \"sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==\",\n      \"dev\": true\n    },\n    \"@types/seedrandom\": {\n      \"version\": \"3.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz\",\n      \"integrity\": \"sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==\",\n      \"dev\": true\n    },\n    \"@types/send\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz\",\n      \"integrity\": \"sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/serve-static\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz\",\n      \"integrity\": \"sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/http-errors\": \"*\",\n        \"@types/node\": \"*\"\n      }\n    },\n    \"@types/webidl-conversions\": {\n      \"version\": \"7.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz\",\n      \"integrity\": \"sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==\"\n    },\n    \"@types/whatwg-url\": {\n      \"version\": \"8.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz\",\n      \"integrity\": \"sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==\",\n      \"requires\": {\n        \"@types/node\": \"*\",\n        \"@types/webidl-conversions\": \"*\"\n      }\n    },\n    \"@typescript-eslint/project-service\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz\",\n      \"integrity\": \"sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@typescript-eslint/tsconfig-utils\": \"^8.56.1\",\n        \"@typescript-eslint/types\": \"^8.56.1\",\n        \"debug\": \"^4.4.3\"\n      },\n      \"dependencies\": {\n        \"@typescript-eslint/types\": {\n          \"version\": \"8.57.0\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz\",\n          \"integrity\": \"sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==\",\n          \"dev\": true\n        }\n      }\n    },\n    \"@typescript-eslint/tsconfig-utils\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz\",\n      \"integrity\": \"sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==\",\n      \"dev\": true,\n      \"requires\": {}\n    },\n    \"accepts\": {\n      \"version\": \"1.3.8\",\n      \"resolved\": \"https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz\",\n      \"integrity\": \"sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==\",\n      \"requires\": {\n        \"mime-types\": \"~2.1.34\",\n        \"negotiator\": \"0.6.3\"\n      }\n    },\n    \"acorn\": {\n      \"version\": \"8.16.0\",\n      \"resolved\": \"https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz\",\n      \"integrity\": \"sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==\",\n      \"dev\": true\n    },\n    \"acorn-jsx\": {\n      \"version\": \"5.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz\",\n      \"integrity\": \"sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==\",\n      \"dev\": true,\n      \"requires\": {}\n    },\n    \"aggregate-error\": {\n      \"version\": \"3.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz\",\n      \"integrity\": \"sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==\",\n      \"requires\": {\n        \"clean-stack\": \"^2.0.0\",\n        \"indent-string\": \"^4.0.0\"\n      }\n    },\n    \"ajv\": {\n      \"version\": \"6.14.0\",\n      \"resolved\": \"https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz\",\n      \"integrity\": \"sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"fast-deep-equal\": \"^3.1.1\",\n        \"fast-json-stable-stringify\": \"^2.0.0\",\n        \"json-schema-traverse\": \"^0.4.1\",\n        \"uri-js\": \"^4.2.2\"\n      }\n    },\n    \"balanced-match\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz\",\n      \"integrity\": \"sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==\",\n      \"dev\": true\n    },\n    \"base64-js\": {\n      \"version\": \"1.5.1\",\n      \"resolved\": \"https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz\",\n      \"integrity\": \"sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==\"\n    },\n    \"boolbase\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz\",\n      \"integrity\": \"sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==\",\n      \"dev\": true\n    },\n    \"bowser\": {\n      \"version\": \"2.14.1\",\n      \"resolved\": \"https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz\",\n      \"integrity\": \"sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==\",\n      \"optional\": true\n    },\n    \"bson\": {\n      \"version\": \"4.7.2\",\n      \"resolved\": \"https://registry.npmjs.org/bson/-/bson-4.7.2.tgz\",\n      \"integrity\": \"sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==\",\n      \"requires\": {\n        \"buffer\": \"^5.6.0\"\n      }\n    },\n    \"buffer\": {\n      \"version\": \"5.7.1\",\n      \"resolved\": \"https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz\",\n      \"integrity\": \"sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==\",\n      \"requires\": {\n        \"base64-js\": \"^1.3.1\",\n        \"ieee754\": \"^1.1.13\"\n      }\n    },\n    \"buffer-equal-constant-time\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz\",\n      \"integrity\": \"sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==\"\n    },\n    \"bytes\": {\n      \"version\": \"3.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz\",\n      \"integrity\": \"sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==\"\n    },\n    \"cache-content-type\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz\",\n      \"integrity\": \"sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==\",\n      \"requires\": {\n        \"mime-types\": \"^2.1.18\",\n        \"ylru\": \"^1.2.0\"\n      }\n    },\n    \"call-bind-apply-helpers\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz\",\n      \"integrity\": \"sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==\",\n      \"requires\": {\n        \"es-errors\": \"^1.3.0\",\n        \"function-bind\": \"^1.1.2\"\n      }\n    },\n    \"call-bound\": {\n      \"version\": \"1.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz\",\n      \"integrity\": \"sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==\",\n      \"requires\": {\n        \"call-bind-apply-helpers\": \"^1.0.2\",\n        \"get-intrinsic\": \"^1.3.0\"\n      }\n    },\n    \"clean-stack\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz\",\n      \"integrity\": \"sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==\"\n    },\n    \"co\": {\n      \"version\": \"4.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/co/-/co-4.6.0.tgz\",\n      \"integrity\": \"sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==\"\n    },\n    \"co-body\": {\n      \"version\": \"6.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/co-body/-/co-body-6.1.0.tgz\",\n      \"integrity\": \"sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==\",\n      \"requires\": {\n        \"inflation\": \"^2.0.0\",\n        \"qs\": \"^6.5.2\",\n        \"raw-body\": \"^2.3.3\",\n        \"type-is\": \"^1.6.16\"\n      }\n    },\n    \"commander\": {\n      \"version\": \"7.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/commander/-/commander-7.2.0.tgz\",\n      \"integrity\": \"sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==\",\n      \"dev\": true\n    },\n    \"compressible\": {\n      \"version\": \"2.0.18\",\n      \"resolved\": \"https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz\",\n      \"integrity\": \"sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==\",\n      \"requires\": {\n        \"mime-db\": \">= 1.43.0 < 2\"\n      }\n    },\n    \"concat-map\": {\n      \"version\": \"0.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz\",\n      \"integrity\": \"sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==\",\n      \"dev\": true\n    },\n    \"content-disposition\": {\n      \"version\": \"0.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz\",\n      \"integrity\": \"sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==\",\n      \"requires\": {\n        \"safe-buffer\": \"5.2.1\"\n      }\n    },\n    \"content-type\": {\n      \"version\": \"1.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz\",\n      \"integrity\": \"sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==\"\n    },\n    \"cookies\": {\n      \"version\": \"0.9.1\",\n      \"resolved\": \"https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz\",\n      \"integrity\": \"sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==\",\n      \"requires\": {\n        \"depd\": \"~2.0.0\",\n        \"keygrip\": \"~1.1.0\"\n      }\n    },\n    \"crelt\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz\",\n      \"integrity\": \"sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==\",\n      \"dev\": true\n    },\n    \"cross-spawn\": {\n      \"version\": \"7.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz\",\n      \"integrity\": \"sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"path-key\": \"^3.1.0\",\n        \"shebang-command\": \"^2.0.0\",\n        \"which\": \"^2.0.1\"\n      }\n    },\n    \"css-select\": {\n      \"version\": \"5.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz\",\n      \"integrity\": \"sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"boolbase\": \"^1.0.0\",\n        \"css-what\": \"^6.1.0\",\n        \"domhandler\": \"^5.0.2\",\n        \"domutils\": \"^3.0.1\",\n        \"nth-check\": \"^2.0.1\"\n      }\n    },\n    \"css-tree\": {\n      \"version\": \"2.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz\",\n      \"integrity\": \"sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"mdn-data\": \"2.0.30\",\n        \"source-map-js\": \"^1.0.1\"\n      }\n    },\n    \"css-what\": {\n      \"version\": \"6.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz\",\n      \"integrity\": \"sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==\",\n      \"dev\": true\n    },\n    \"csso\": {\n      \"version\": \"5.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/csso/-/csso-5.0.5.tgz\",\n      \"integrity\": \"sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"css-tree\": \"~2.2.0\"\n      },\n      \"dependencies\": {\n        \"css-tree\": {\n          \"version\": \"2.2.1\",\n          \"resolved\": \"https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz\",\n          \"integrity\": \"sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==\",\n          \"dev\": true,\n          \"requires\": {\n            \"mdn-data\": \"2.0.28\",\n            \"source-map-js\": \"^1.0.1\"\n          }\n        },\n        \"mdn-data\": {\n          \"version\": \"2.0.28\",\n          \"resolved\": \"https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz\",\n          \"integrity\": \"sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==\",\n          \"dev\": true\n        }\n      }\n    },\n    \"debug\": {\n      \"version\": \"4.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/debug/-/debug-4.4.3.tgz\",\n      \"integrity\": \"sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==\",\n      \"requires\": {\n        \"ms\": \"^2.1.3\"\n      }\n    },\n    \"deep-equal\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz\",\n      \"integrity\": \"sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==\"\n    },\n    \"deep-is\": {\n      \"version\": \"0.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz\",\n      \"integrity\": \"sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==\",\n      \"dev\": true\n    },\n    \"delegates\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz\",\n      \"integrity\": \"sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==\"\n    },\n    \"depd\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/depd/-/depd-2.0.0.tgz\",\n      \"integrity\": \"sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==\"\n    },\n    \"destroy\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz\",\n      \"integrity\": \"sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==\"\n    },\n    \"detect-libc\": {\n      \"version\": \"2.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz\",\n      \"integrity\": \"sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==\",\n      \"dev\": true\n    },\n    \"dom-serializer\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz\",\n      \"integrity\": \"sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==\",\n      \"dev\": true,\n      \"requires\": {\n        \"domelementtype\": \"^2.3.0\",\n        \"domhandler\": \"^5.0.2\",\n        \"entities\": \"^4.2.0\"\n      }\n    },\n    \"domelementtype\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz\",\n      \"integrity\": \"sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==\",\n      \"dev\": true\n    },\n    \"domhandler\": {\n      \"version\": \"5.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz\",\n      \"integrity\": \"sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==\",\n      \"dev\": true,\n      \"requires\": {\n        \"domelementtype\": \"^2.3.0\"\n      }\n    },\n    \"domutils\": {\n      \"version\": \"3.2.2\",\n      \"resolved\": \"https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz\",\n      \"integrity\": \"sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"dom-serializer\": \"^2.0.0\",\n        \"domelementtype\": \"^2.3.0\",\n        \"domhandler\": \"^5.0.3\"\n      }\n    },\n    \"dunder-proto\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz\",\n      \"integrity\": \"sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==\",\n      \"requires\": {\n        \"call-bind-apply-helpers\": \"^1.0.1\",\n        \"es-errors\": \"^1.3.0\",\n        \"gopd\": \"^1.2.0\"\n      }\n    },\n    \"ecdsa-sig-formatter\": {\n      \"version\": \"1.0.11\",\n      \"resolved\": \"https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz\",\n      \"integrity\": \"sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==\",\n      \"requires\": {\n        \"safe-buffer\": \"^5.0.1\"\n      }\n    },\n    \"ee-first\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz\",\n      \"integrity\": \"sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==\"\n    },\n    \"encodeurl\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz\",\n      \"integrity\": \"sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==\"\n    },\n    \"enhanced-resolve\": {\n      \"version\": \"5.20.0\",\n      \"resolved\": \"https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz\",\n      \"integrity\": \"sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"graceful-fs\": \"^4.2.4\",\n        \"tapable\": \"^2.3.0\"\n      }\n    },\n    \"entities\": {\n      \"version\": \"4.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/entities/-/entities-4.5.0.tgz\",\n      \"integrity\": \"sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==\",\n      \"dev\": true\n    },\n    \"es-define-property\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz\",\n      \"integrity\": \"sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==\"\n    },\n    \"es-errors\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz\",\n      \"integrity\": \"sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==\"\n    },\n    \"es-object-atoms\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz\",\n      \"integrity\": \"sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==\",\n      \"requires\": {\n        \"es-errors\": \"^1.3.0\"\n      }\n    },\n    \"esbuild\": {\n      \"version\": \"0.27.4\",\n      \"resolved\": \"https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz\",\n      \"integrity\": \"sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@esbuild/aix-ppc64\": \"0.27.4\",\n        \"@esbuild/android-arm\": \"0.27.4\",\n        \"@esbuild/android-arm64\": \"0.27.4\",\n        \"@esbuild/android-x64\": \"0.27.4\",\n        \"@esbuild/darwin-arm64\": \"0.27.4\",\n        \"@esbuild/darwin-x64\": \"0.27.4\",\n        \"@esbuild/freebsd-arm64\": \"0.27.4\",\n        \"@esbuild/freebsd-x64\": \"0.27.4\",\n        \"@esbuild/linux-arm\": \"0.27.4\",\n        \"@esbuild/linux-arm64\": \"0.27.4\",\n        \"@esbuild/linux-ia32\": \"0.27.4\",\n        \"@esbuild/linux-loong64\": \"0.27.4\",\n        \"@esbuild/linux-mips64el\": \"0.27.4\",\n        \"@esbuild/linux-ppc64\": \"0.27.4\",\n        \"@esbuild/linux-riscv64\": \"0.27.4\",\n        \"@esbuild/linux-s390x\": \"0.27.4\",\n        \"@esbuild/linux-x64\": \"0.27.4\",\n        \"@esbuild/netbsd-arm64\": \"0.27.4\",\n        \"@esbuild/netbsd-x64\": \"0.27.4\",\n        \"@esbuild/openbsd-arm64\": \"0.27.4\",\n        \"@esbuild/openbsd-x64\": \"0.27.4\",\n        \"@esbuild/openharmony-arm64\": \"0.27.4\",\n        \"@esbuild/sunos-x64\": \"0.27.4\",\n        \"@esbuild/win32-arm64\": \"0.27.4\",\n        \"@esbuild/win32-ia32\": \"0.27.4\",\n        \"@esbuild/win32-x64\": \"0.27.4\"\n      }\n    },\n    \"escape-html\": {\n      \"version\": \"1.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz\",\n      \"integrity\": \"sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==\"\n    },\n    \"escape-string-regexp\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz\",\n      \"integrity\": \"sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==\",\n      \"dev\": true\n    },\n    \"eslint\": {\n      \"version\": \"10.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz\",\n      \"integrity\": \"sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@eslint-community/eslint-utils\": \"^4.8.0\",\n        \"@eslint-community/regexpp\": \"^4.12.2\",\n        \"@eslint/config-array\": \"^0.23.3\",\n        \"@eslint/config-helpers\": \"^0.5.2\",\n        \"@eslint/core\": \"^1.1.1\",\n        \"@eslint/plugin-kit\": \"^0.6.1\",\n        \"@humanfs/node\": \"^0.16.6\",\n        \"@humanwhocodes/module-importer\": \"^1.0.1\",\n        \"@humanwhocodes/retry\": \"^0.4.2\",\n        \"@types/estree\": \"^1.0.6\",\n        \"ajv\": \"^6.14.0\",\n        \"cross-spawn\": \"^7.0.6\",\n        \"debug\": \"^4.3.2\",\n        \"escape-string-regexp\": \"^4.0.0\",\n        \"eslint-scope\": \"^9.1.2\",\n        \"eslint-visitor-keys\": \"^5.0.1\",\n        \"espree\": \"^11.1.1\",\n        \"esquery\": \"^1.7.0\",\n        \"esutils\": \"^2.0.2\",\n        \"fast-deep-equal\": \"^3.1.3\",\n        \"file-entry-cache\": \"^8.0.0\",\n        \"find-up\": \"^5.0.0\",\n        \"glob-parent\": \"^6.0.2\",\n        \"ignore\": \"^5.2.0\",\n        \"imurmurhash\": \"^0.1.4\",\n        \"is-glob\": \"^4.0.0\",\n        \"json-stable-stringify-without-jsonify\": \"^1.0.1\",\n        \"minimatch\": \"^10.2.4\",\n        \"natural-compare\": \"^1.4.0\",\n        \"optionator\": \"^0.9.3\"\n      },\n      \"dependencies\": {\n        \"balanced-match\": {\n          \"version\": \"4.0.4\",\n          \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz\",\n          \"integrity\": \"sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==\",\n          \"dev\": true\n        },\n        \"brace-expansion\": {\n          \"version\": \"5.0.4\",\n          \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz\",\n          \"integrity\": \"sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"balanced-match\": \"^4.0.2\"\n          }\n        },\n        \"eslint-visitor-keys\": {\n          \"version\": \"5.0.1\",\n          \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz\",\n          \"integrity\": \"sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==\",\n          \"dev\": true\n        },\n        \"minimatch\": {\n          \"version\": \"10.2.4\",\n          \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz\",\n          \"integrity\": \"sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"brace-expansion\": \"^5.0.2\"\n          }\n        }\n      }\n    },\n    \"eslint-config-prettier\": {\n      \"version\": \"10.1.8\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz\",\n      \"integrity\": \"sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==\",\n      \"dev\": true,\n      \"requires\": {}\n    },\n    \"eslint-scope\": {\n      \"version\": \"9.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz\",\n      \"integrity\": \"sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@types/esrecurse\": \"^4.3.1\",\n        \"@types/estree\": \"^1.0.8\",\n        \"esrecurse\": \"^4.3.0\",\n        \"estraverse\": \"^5.2.0\"\n      }\n    },\n    \"eslint-visitor-keys\": {\n      \"version\": \"3.4.3\",\n      \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz\",\n      \"integrity\": \"sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==\",\n      \"dev\": true\n    },\n    \"espree\": {\n      \"version\": \"11.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/espree/-/espree-11.2.0.tgz\",\n      \"integrity\": \"sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"acorn\": \"^8.16.0\",\n        \"acorn-jsx\": \"^5.3.2\",\n        \"eslint-visitor-keys\": \"^5.0.1\"\n      },\n      \"dependencies\": {\n        \"eslint-visitor-keys\": {\n          \"version\": \"5.0.1\",\n          \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz\",\n          \"integrity\": \"sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==\",\n          \"dev\": true\n        }\n      }\n    },\n    \"espresso-iisojs\": {\n      \"version\": \"1.0.8\",\n      \"resolved\": \"https://registry.npmjs.org/espresso-iisojs/-/espresso-iisojs-1.0.8.tgz\",\n      \"integrity\": \"sha512-S3D62BA/jBUCIQJ3VlBGMSxGwPyyV7ttduiJvyaD4dWyhiC/9TnJTaiurx4r8bqcYZ1J0KELliub4xo8neKjSA==\"\n    },\n    \"esquery\": {\n      \"version\": \"1.7.0\",\n      \"resolved\": \"https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz\",\n      \"integrity\": \"sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==\",\n      \"dev\": true,\n      \"requires\": {\n        \"estraverse\": \"^5.1.0\"\n      }\n    },\n    \"esrecurse\": {\n      \"version\": \"4.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz\",\n      \"integrity\": \"sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==\",\n      \"dev\": true,\n      \"requires\": {\n        \"estraverse\": \"^5.2.0\"\n      }\n    },\n    \"estraverse\": {\n      \"version\": \"5.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz\",\n      \"integrity\": \"sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==\",\n      \"dev\": true\n    },\n    \"esutils\": {\n      \"version\": \"2.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz\",\n      \"integrity\": \"sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==\",\n      \"dev\": true\n    },\n    \"fast-deep-equal\": {\n      \"version\": \"3.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz\",\n      \"integrity\": \"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==\",\n      \"dev\": true\n    },\n    \"fast-json-stable-stringify\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz\",\n      \"integrity\": \"sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==\",\n      \"dev\": true\n    },\n    \"fast-levenshtein\": {\n      \"version\": \"2.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz\",\n      \"integrity\": \"sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==\",\n      \"dev\": true\n    },\n    \"fast-xml-builder\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz\",\n      \"integrity\": \"sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==\",\n      \"optional\": true,\n      \"requires\": {\n        \"path-expression-matcher\": \"^1.1.3\"\n      }\n    },\n    \"fast-xml-parser\": {\n      \"version\": \"5.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz\",\n      \"integrity\": \"sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==\",\n      \"optional\": true,\n      \"requires\": {\n        \"fast-xml-builder\": \"^1.0.0\",\n        \"strnum\": \"^2.1.2\"\n      }\n    },\n    \"file-entry-cache\": {\n      \"version\": \"8.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz\",\n      \"integrity\": \"sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"flat-cache\": \"^4.0.0\"\n      }\n    },\n    \"find-up\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz\",\n      \"integrity\": \"sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==\",\n      \"dev\": true,\n      \"requires\": {\n        \"locate-path\": \"^6.0.0\",\n        \"path-exists\": \"^4.0.0\"\n      }\n    },\n    \"flat-cache\": {\n      \"version\": \"4.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz\",\n      \"integrity\": \"sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"flatted\": \"^3.2.9\",\n        \"keyv\": \"^4.5.4\"\n      }\n    },\n    \"flatted\": {\n      \"version\": \"3.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz\",\n      \"integrity\": \"sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==\",\n      \"dev\": true\n    },\n    \"fresh\": {\n      \"version\": \"0.5.2\",\n      \"resolved\": \"https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz\",\n      \"integrity\": \"sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==\"\n    },\n    \"function-bind\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz\",\n      \"integrity\": \"sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==\"\n    },\n    \"generator-function\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz\",\n      \"integrity\": \"sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==\"\n    },\n    \"get-intrinsic\": {\n      \"version\": \"1.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz\",\n      \"integrity\": \"sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==\",\n      \"requires\": {\n        \"call-bind-apply-helpers\": \"^1.0.2\",\n        \"es-define-property\": \"^1.0.1\",\n        \"es-errors\": \"^1.3.0\",\n        \"es-object-atoms\": \"^1.1.1\",\n        \"function-bind\": \"^1.1.2\",\n        \"get-proto\": \"^1.0.1\",\n        \"gopd\": \"^1.2.0\",\n        \"has-symbols\": \"^1.1.0\",\n        \"hasown\": \"^2.0.2\",\n        \"math-intrinsics\": \"^1.1.0\"\n      }\n    },\n    \"get-proto\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz\",\n      \"integrity\": \"sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==\",\n      \"requires\": {\n        \"dunder-proto\": \"^1.0.1\",\n        \"es-object-atoms\": \"^1.0.0\"\n      }\n    },\n    \"glob-parent\": {\n      \"version\": \"6.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz\",\n      \"integrity\": \"sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==\",\n      \"dev\": true,\n      \"requires\": {\n        \"is-glob\": \"^4.0.3\"\n      }\n    },\n    \"globals\": {\n      \"version\": \"17.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/globals/-/globals-17.4.0.tgz\",\n      \"integrity\": \"sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==\",\n      \"dev\": true\n    },\n    \"gopd\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz\",\n      \"integrity\": \"sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==\"\n    },\n    \"graceful-fs\": {\n      \"version\": \"4.2.11\",\n      \"resolved\": \"https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz\",\n      \"integrity\": \"sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==\",\n      \"dev\": true\n    },\n    \"has-symbols\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz\",\n      \"integrity\": \"sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==\"\n    },\n    \"has-tostringtag\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz\",\n      \"integrity\": \"sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==\",\n      \"requires\": {\n        \"has-symbols\": \"^1.0.3\"\n      }\n    },\n    \"hasown\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz\",\n      \"integrity\": \"sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==\",\n      \"requires\": {\n        \"function-bind\": \"^1.1.2\"\n      }\n    },\n    \"http-assert\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz\",\n      \"integrity\": \"sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==\",\n      \"requires\": {\n        \"deep-equal\": \"~1.0.1\",\n        \"http-errors\": \"~1.8.0\"\n      },\n      \"dependencies\": {\n        \"depd\": {\n          \"version\": \"1.1.2\",\n          \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n          \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\"\n        },\n        \"http-errors\": {\n          \"version\": \"1.8.1\",\n          \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz\",\n          \"integrity\": \"sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==\",\n          \"requires\": {\n            \"depd\": \"~1.1.2\",\n            \"inherits\": \"2.0.4\",\n            \"setprototypeof\": \"1.2.0\",\n            \"statuses\": \">= 1.5.0 < 2\",\n            \"toidentifier\": \"1.0.1\"\n          }\n        },\n        \"statuses\": {\n          \"version\": \"1.5.0\",\n          \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n          \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\"\n        }\n      }\n    },\n    \"http-errors\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz\",\n      \"integrity\": \"sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==\",\n      \"requires\": {\n        \"depd\": \"~2.0.0\",\n        \"inherits\": \"~2.0.4\",\n        \"setprototypeof\": \"~1.2.0\",\n        \"statuses\": \"~2.0.2\",\n        \"toidentifier\": \"~1.0.1\"\n      }\n    },\n    \"iconv-lite\": {\n      \"version\": \"0.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz\",\n      \"integrity\": \"sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==\",\n      \"requires\": {\n        \"safer-buffer\": \">= 2.1.2 < 3.0.0\"\n      }\n    },\n    \"ieee754\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz\",\n      \"integrity\": \"sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==\"\n    },\n    \"ignore\": {\n      \"version\": \"5.3.2\",\n      \"resolved\": \"https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz\",\n      \"integrity\": \"sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==\",\n      \"dev\": true\n    },\n    \"imurmurhash\": {\n      \"version\": \"0.1.4\",\n      \"resolved\": \"https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz\",\n      \"integrity\": \"sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==\",\n      \"dev\": true\n    },\n    \"indent-string\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz\",\n      \"integrity\": \"sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==\"\n    },\n    \"inflation\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/inflation/-/inflation-2.1.0.tgz\",\n      \"integrity\": \"sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==\"\n    },\n    \"inherits\": {\n      \"version\": \"2.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz\",\n      \"integrity\": \"sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==\"\n    },\n    \"ip-address\": {\n      \"version\": \"10.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz\",\n      \"integrity\": \"sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==\"\n    },\n    \"ipaddr.js\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz\",\n      \"integrity\": \"sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==\"\n    },\n    \"is-extglob\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz\",\n      \"integrity\": \"sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==\",\n      \"dev\": true\n    },\n    \"is-generator-function\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz\",\n      \"integrity\": \"sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==\",\n      \"requires\": {\n        \"call-bound\": \"^1.0.4\",\n        \"generator-function\": \"^2.0.0\",\n        \"get-proto\": \"^1.0.1\",\n        \"has-tostringtag\": \"^1.0.2\",\n        \"safe-regex-test\": \"^1.1.0\"\n      }\n    },\n    \"is-glob\": {\n      \"version\": \"4.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz\",\n      \"integrity\": \"sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==\",\n      \"dev\": true,\n      \"requires\": {\n        \"is-extglob\": \"^2.1.1\"\n      }\n    },\n    \"is-regex\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz\",\n      \"integrity\": \"sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==\",\n      \"requires\": {\n        \"call-bound\": \"^1.0.2\",\n        \"gopd\": \"^1.2.0\",\n        \"has-tostringtag\": \"^1.0.2\",\n        \"hasown\": \"^2.0.2\"\n      }\n    },\n    \"isexe\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz\",\n      \"integrity\": \"sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==\",\n      \"dev\": true\n    },\n    \"jiti\": {\n      \"version\": \"2.6.1\",\n      \"resolved\": \"https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz\",\n      \"integrity\": \"sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==\",\n      \"dev\": true\n    },\n    \"json-buffer\": {\n      \"version\": \"3.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz\",\n      \"integrity\": \"sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==\",\n      \"dev\": true\n    },\n    \"json-schema-traverse\": {\n      \"version\": \"0.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz\",\n      \"integrity\": \"sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==\",\n      \"dev\": true\n    },\n    \"json-stable-stringify-without-jsonify\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz\",\n      \"integrity\": \"sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==\",\n      \"dev\": true\n    },\n    \"jsonwebtoken\": {\n      \"version\": \"9.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz\",\n      \"integrity\": \"sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==\",\n      \"requires\": {\n        \"jws\": \"^4.0.1\",\n        \"lodash.includes\": \"^4.3.0\",\n        \"lodash.isboolean\": \"^3.0.3\",\n        \"lodash.isinteger\": \"^4.0.4\",\n        \"lodash.isnumber\": \"^3.0.3\",\n        \"lodash.isplainobject\": \"^4.0.6\",\n        \"lodash.isstring\": \"^4.0.1\",\n        \"lodash.once\": \"^4.0.0\",\n        \"ms\": \"^2.1.1\",\n        \"semver\": \"^7.5.4\"\n      }\n    },\n    \"jwa\": {\n      \"version\": \"2.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz\",\n      \"integrity\": \"sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==\",\n      \"requires\": {\n        \"buffer-equal-constant-time\": \"^1.0.1\",\n        \"ecdsa-sig-formatter\": \"1.0.11\",\n        \"safe-buffer\": \"^5.0.1\"\n      }\n    },\n    \"jws\": {\n      \"version\": \"4.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/jws/-/jws-4.0.1.tgz\",\n      \"integrity\": \"sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==\",\n      \"requires\": {\n        \"jwa\": \"^2.0.1\",\n        \"safe-buffer\": \"^5.0.1\"\n      }\n    },\n    \"keygrip\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz\",\n      \"integrity\": \"sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==\",\n      \"requires\": {\n        \"tsscmp\": \"1.0.6\"\n      }\n    },\n    \"keyv\": {\n      \"version\": \"4.5.4\",\n      \"resolved\": \"https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz\",\n      \"integrity\": \"sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"json-buffer\": \"3.0.1\"\n      }\n    },\n    \"koa\": {\n      \"version\": \"2.16.4\",\n      \"resolved\": \"https://registry.npmjs.org/koa/-/koa-2.16.4.tgz\",\n      \"integrity\": \"sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==\",\n      \"requires\": {\n        \"accepts\": \"^1.3.5\",\n        \"cache-content-type\": \"^1.0.0\",\n        \"content-disposition\": \"~0.5.2\",\n        \"content-type\": \"^1.0.4\",\n        \"cookies\": \"~0.9.0\",\n        \"debug\": \"^4.3.2\",\n        \"delegates\": \"^1.0.0\",\n        \"depd\": \"^2.0.0\",\n        \"destroy\": \"^1.0.4\",\n        \"encodeurl\": \"^1.0.2\",\n        \"escape-html\": \"^1.0.3\",\n        \"fresh\": \"~0.5.2\",\n        \"http-assert\": \"^1.3.0\",\n        \"http-errors\": \"^1.6.3\",\n        \"is-generator-function\": \"^1.0.7\",\n        \"koa-compose\": \"^4.1.0\",\n        \"koa-convert\": \"^2.0.0\",\n        \"on-finished\": \"^2.3.0\",\n        \"only\": \"~0.0.2\",\n        \"parseurl\": \"^1.3.2\",\n        \"statuses\": \"^1.5.0\",\n        \"type-is\": \"^1.6.16\",\n        \"vary\": \"^1.1.2\"\n      },\n      \"dependencies\": {\n        \"http-errors\": {\n          \"version\": \"1.8.1\",\n          \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz\",\n          \"integrity\": \"sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==\",\n          \"requires\": {\n            \"depd\": \"~1.1.2\",\n            \"inherits\": \"2.0.4\",\n            \"setprototypeof\": \"1.2.0\",\n            \"statuses\": \">= 1.5.0 < 2\",\n            \"toidentifier\": \"1.0.1\"\n          },\n          \"dependencies\": {\n            \"depd\": {\n              \"version\": \"1.1.2\",\n              \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n              \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\"\n            }\n          }\n        },\n        \"statuses\": {\n          \"version\": \"1.5.0\",\n          \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n          \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\"\n        }\n      }\n    },\n    \"koa-compose\": {\n      \"version\": \"4.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz\",\n      \"integrity\": \"sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==\"\n    },\n    \"koa-compress\": {\n      \"version\": \"5.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-compress/-/koa-compress-5.2.0.tgz\",\n      \"integrity\": \"sha512-RsRnI+v+/rs1lYpcAUcxowUzHYssf71qbMr0Mpdq1wktbtXDZmxBIgxJHtaEsBjSe4jiWYELpGFbASa2AemmOg==\",\n      \"requires\": {\n        \"bytes\": \"^3.1.2\",\n        \"compressible\": \"^2.0.18\",\n        \"http-errors\": \"^2.0.1\",\n        \"koa-is-json\": \"^1.0.0\",\n        \"negotiator\": \"^1.0.0\"\n      },\n      \"dependencies\": {\n        \"negotiator\": {\n          \"version\": \"1.0.0\",\n          \"resolved\": \"https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz\",\n          \"integrity\": \"sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==\"\n        }\n      }\n    },\n    \"koa-convert\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz\",\n      \"integrity\": \"sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==\",\n      \"requires\": {\n        \"co\": \"^4.6.0\",\n        \"koa-compose\": \"^4.1.0\"\n      }\n    },\n    \"koa-is-json\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/koa-is-json/-/koa-is-json-1.0.0.tgz\",\n      \"integrity\": \"sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==\"\n    },\n    \"koa-jwt\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/koa-jwt/-/koa-jwt-4.0.4.tgz\",\n      \"integrity\": \"sha512-Tid9BQfpVtUG/8YZV38a+hDKll0pfVhfl7A/2cNaYThS1cxMFXylZzfARqHQqvNhHy9qM+qkxd4/z6EaIV4SAQ==\",\n      \"requires\": {\n        \"jsonwebtoken\": \"^9.0.0\",\n        \"koa-unless\": \"^1.0.7\",\n        \"p-any\": \"^2.1.0\"\n      }\n    },\n    \"koa-send\": {\n      \"version\": \"5.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz\",\n      \"integrity\": \"sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==\",\n      \"requires\": {\n        \"debug\": \"^4.1.1\",\n        \"http-errors\": \"^1.7.3\",\n        \"resolve-path\": \"^1.4.0\"\n      },\n      \"dependencies\": {\n        \"depd\": {\n          \"version\": \"1.1.2\",\n          \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n          \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\"\n        },\n        \"http-errors\": {\n          \"version\": \"1.8.1\",\n          \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz\",\n          \"integrity\": \"sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==\",\n          \"requires\": {\n            \"depd\": \"~1.1.2\",\n            \"inherits\": \"2.0.4\",\n            \"setprototypeof\": \"1.2.0\",\n            \"statuses\": \">= 1.5.0 < 2\",\n            \"toidentifier\": \"1.0.1\"\n          }\n        },\n        \"statuses\": {\n          \"version\": \"1.5.0\",\n          \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n          \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\"\n        }\n      }\n    },\n    \"koa-unless\": {\n      \"version\": \"1.0.7\",\n      \"resolved\": \"https://registry.npmjs.org/koa-unless/-/koa-unless-1.0.7.tgz\",\n      \"integrity\": \"sha512-NKiz+nk4KxSJFskiJMuJvxeA41Lcnx3d8Zy+8QETgifm4ab4aOeGD3RgR6bIz0FGNWwo3Fz0DtnK77mEIqHWxA==\"\n    },\n    \"levn\": {\n      \"version\": \"0.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/levn/-/levn-0.4.1.tgz\",\n      \"integrity\": \"sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"prelude-ls\": \"^1.2.1\",\n        \"type-check\": \"~0.4.0\"\n      }\n    },\n    \"lightningcss\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz\",\n      \"integrity\": \"sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"detect-libc\": \"^2.0.3\",\n        \"lightningcss-android-arm64\": \"1.31.1\",\n        \"lightningcss-darwin-arm64\": \"1.31.1\",\n        \"lightningcss-darwin-x64\": \"1.31.1\",\n        \"lightningcss-freebsd-x64\": \"1.31.1\",\n        \"lightningcss-linux-arm-gnueabihf\": \"1.31.1\",\n        \"lightningcss-linux-arm64-gnu\": \"1.31.1\",\n        \"lightningcss-linux-arm64-musl\": \"1.31.1\",\n        \"lightningcss-linux-x64-gnu\": \"1.31.1\",\n        \"lightningcss-linux-x64-musl\": \"1.31.1\",\n        \"lightningcss-win32-arm64-msvc\": \"1.31.1\",\n        \"lightningcss-win32-x64-msvc\": \"1.31.1\"\n      }\n    },\n    \"lightningcss-android-arm64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz\",\n      \"integrity\": \"sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-darwin-arm64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz\",\n      \"integrity\": \"sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-darwin-x64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz\",\n      \"integrity\": \"sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-freebsd-x64\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz\",\n      \"integrity\": \"sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-linux-arm-gnueabihf\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz\",\n      \"integrity\": \"sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-linux-arm64-gnu\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz\",\n      \"integrity\": \"sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-linux-arm64-musl\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz\",\n      \"integrity\": \"sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-linux-x64-gnu\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz\",\n      \"integrity\": \"sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-linux-x64-musl\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz\",\n      \"integrity\": \"sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-win32-arm64-msvc\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz\",\n      \"integrity\": \"sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"lightningcss-win32-x64-msvc\": {\n      \"version\": \"1.31.1\",\n      \"resolved\": \"https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz\",\n      \"integrity\": \"sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==\",\n      \"dev\": true,\n      \"optional\": true\n    },\n    \"locate-path\": {\n      \"version\": \"6.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz\",\n      \"integrity\": \"sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"p-locate\": \"^5.0.0\"\n      }\n    },\n    \"lodash.includes\": {\n      \"version\": \"4.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz\",\n      \"integrity\": \"sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==\"\n    },\n    \"lodash.isboolean\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz\",\n      \"integrity\": \"sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==\"\n    },\n    \"lodash.isinteger\": {\n      \"version\": \"4.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz\",\n      \"integrity\": \"sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==\"\n    },\n    \"lodash.isnumber\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz\",\n      \"integrity\": \"sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==\"\n    },\n    \"lodash.isplainobject\": {\n      \"version\": \"4.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz\",\n      \"integrity\": \"sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==\"\n    },\n    \"lodash.isstring\": {\n      \"version\": \"4.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz\",\n      \"integrity\": \"sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==\"\n    },\n    \"lodash.merge\": {\n      \"version\": \"4.6.2\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz\",\n      \"integrity\": \"sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==\"\n    },\n    \"lodash.once\": {\n      \"version\": \"4.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz\",\n      \"integrity\": \"sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==\"\n    },\n    \"magic-string\": {\n      \"version\": \"0.30.21\",\n      \"resolved\": \"https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz\",\n      \"integrity\": \"sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@jridgewell/sourcemap-codec\": \"^1.5.5\"\n      }\n    },\n    \"math-intrinsics\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz\",\n      \"integrity\": \"sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==\"\n    },\n    \"mdn-data\": {\n      \"version\": \"2.0.30\",\n      \"resolved\": \"https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz\",\n      \"integrity\": \"sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==\",\n      \"dev\": true\n    },\n    \"media-typer\": {\n      \"version\": \"0.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz\",\n      \"integrity\": \"sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==\"\n    },\n    \"memory-pager\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz\",\n      \"integrity\": \"sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==\",\n      \"optional\": true\n    },\n    \"mime-db\": {\n      \"version\": \"1.54.0\",\n      \"resolved\": \"https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz\",\n      \"integrity\": \"sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==\"\n    },\n    \"mime-types\": {\n      \"version\": \"2.1.35\",\n      \"resolved\": \"https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz\",\n      \"integrity\": \"sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==\",\n      \"requires\": {\n        \"mime-db\": \"1.52.0\"\n      },\n      \"dependencies\": {\n        \"mime-db\": {\n          \"version\": \"1.52.0\",\n          \"resolved\": \"https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz\",\n          \"integrity\": \"sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==\"\n        }\n      }\n    },\n    \"mini-svg-data-uri\": {\n      \"version\": \"1.4.4\",\n      \"resolved\": \"https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz\",\n      \"integrity\": \"sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==\",\n      \"dev\": true\n    },\n    \"mithril\": {\n      \"version\": \"2.3.8\",\n      \"resolved\": \"https://registry.npmjs.org/mithril/-/mithril-2.3.8.tgz\",\n      \"integrity\": \"sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ==\",\n      \"dev\": true\n    },\n    \"mongodb\": {\n      \"version\": \"4.17.2\",\n      \"resolved\": \"https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz\",\n      \"integrity\": \"sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==\",\n      \"requires\": {\n        \"@aws-sdk/credential-providers\": \"^3.186.0\",\n        \"@mongodb-js/saslprep\": \"^1.1.0\",\n        \"bson\": \"^4.7.2\",\n        \"mongodb-connection-string-url\": \"^2.6.0\",\n        \"socks\": \"^2.7.1\"\n      }\n    },\n    \"mongodb-connection-string-url\": {\n      \"version\": \"2.6.0\",\n      \"resolved\": \"https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz\",\n      \"integrity\": \"sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==\",\n      \"requires\": {\n        \"@types/whatwg-url\": \"^8.2.1\",\n        \"whatwg-url\": \"^11.0.0\"\n      }\n    },\n    \"mri\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/mri/-/mri-1.2.0.tgz\",\n      \"integrity\": \"sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==\",\n      \"dev\": true\n    },\n    \"ms\": {\n      \"version\": \"2.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/ms/-/ms-2.1.3.tgz\",\n      \"integrity\": \"sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==\"\n    },\n    \"natural-compare\": {\n      \"version\": \"1.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz\",\n      \"integrity\": \"sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==\",\n      \"dev\": true\n    },\n    \"negotiator\": {\n      \"version\": \"0.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz\",\n      \"integrity\": \"sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==\"\n    },\n    \"node-addon-api\": {\n      \"version\": \"7.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz\",\n      \"integrity\": \"sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==\",\n      \"dev\": true\n    },\n    \"nth-check\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz\",\n      \"integrity\": \"sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==\",\n      \"dev\": true,\n      \"requires\": {\n        \"boolbase\": \"^1.0.0\"\n      }\n    },\n    \"object-inspect\": {\n      \"version\": \"1.13.4\",\n      \"resolved\": \"https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz\",\n      \"integrity\": \"sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==\"\n    },\n    \"on-finished\": {\n      \"version\": \"2.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz\",\n      \"integrity\": \"sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==\",\n      \"requires\": {\n        \"ee-first\": \"1.1.1\"\n      }\n    },\n    \"only\": {\n      \"version\": \"0.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/only/-/only-0.0.2.tgz\",\n      \"integrity\": \"sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==\"\n    },\n    \"optionator\": {\n      \"version\": \"0.9.4\",\n      \"resolved\": \"https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz\",\n      \"integrity\": \"sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==\",\n      \"dev\": true,\n      \"requires\": {\n        \"deep-is\": \"^0.1.3\",\n        \"fast-levenshtein\": \"^2.0.6\",\n        \"levn\": \"^0.4.1\",\n        \"prelude-ls\": \"^1.2.1\",\n        \"type-check\": \"^0.4.0\",\n        \"word-wrap\": \"^1.2.5\"\n      }\n    },\n    \"p-any\": {\n      \"version\": \"2.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-any/-/p-any-2.1.0.tgz\",\n      \"integrity\": \"sha512-JAERcaMBLYKMq+voYw36+x5Dgh47+/o7yuv2oQYuSSUml4YeqJEFznBrY2UeEkoSHqBua6hz518n/PsowTYLLg==\",\n      \"requires\": {\n        \"p-cancelable\": \"^2.0.0\",\n        \"p-some\": \"^4.0.0\",\n        \"type-fest\": \"^0.3.0\"\n      },\n      \"dependencies\": {\n        \"type-fest\": {\n          \"version\": \"0.3.1\",\n          \"resolved\": \"https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz\",\n          \"integrity\": \"sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==\"\n        }\n      }\n    },\n    \"p-cancelable\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz\",\n      \"integrity\": \"sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==\"\n    },\n    \"p-limit\": {\n      \"version\": \"3.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz\",\n      \"integrity\": \"sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"yocto-queue\": \"^0.1.0\"\n      }\n    },\n    \"p-locate\": {\n      \"version\": \"5.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz\",\n      \"integrity\": \"sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==\",\n      \"dev\": true,\n      \"requires\": {\n        \"p-limit\": \"^3.0.2\"\n      }\n    },\n    \"p-some\": {\n      \"version\": \"4.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/p-some/-/p-some-4.1.0.tgz\",\n      \"integrity\": \"sha512-MF/HIbq6GeBqTrTIl5OJubzkGU+qfFhAFi0gnTAK6rgEIJIknEiABHOTtQu4e6JiXjIwuMPMUFQzyHh5QjCl1g==\",\n      \"requires\": {\n        \"aggregate-error\": \"^3.0.0\",\n        \"p-cancelable\": \"^2.0.0\"\n      }\n    },\n    \"parseurl\": {\n      \"version\": \"1.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz\",\n      \"integrity\": \"sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==\"\n    },\n    \"path-exists\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz\",\n      \"integrity\": \"sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==\",\n      \"dev\": true\n    },\n    \"path-expression-matcher\": {\n      \"version\": \"1.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz\",\n      \"integrity\": \"sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==\",\n      \"optional\": true\n    },\n    \"path-is-absolute\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz\",\n      \"integrity\": \"sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==\"\n    },\n    \"path-key\": {\n      \"version\": \"3.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz\",\n      \"integrity\": \"sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==\",\n      \"dev\": true\n    },\n    \"path-to-regexp\": {\n      \"version\": \"6.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz\",\n      \"integrity\": \"sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==\"\n    },\n    \"picocolors\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz\",\n      \"integrity\": \"sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==\",\n      \"dev\": true\n    },\n    \"prelude-ls\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz\",\n      \"integrity\": \"sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==\",\n      \"dev\": true\n    },\n    \"prettier\": {\n      \"version\": \"3.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz\",\n      \"integrity\": \"sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==\",\n      \"dev\": true\n    },\n    \"punycode\": {\n      \"version\": \"2.3.1\",\n      \"resolved\": \"https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz\",\n      \"integrity\": \"sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==\"\n    },\n    \"qs\": {\n      \"version\": \"6.15.0\",\n      \"resolved\": \"https://registry.npmjs.org/qs/-/qs-6.15.0.tgz\",\n      \"integrity\": \"sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==\",\n      \"requires\": {\n        \"side-channel\": \"^1.1.0\"\n      }\n    },\n    \"raw-body\": {\n      \"version\": \"2.5.3\",\n      \"resolved\": \"https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz\",\n      \"integrity\": \"sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==\",\n      \"requires\": {\n        \"bytes\": \"~3.1.2\",\n        \"http-errors\": \"~2.0.1\",\n        \"iconv-lite\": \"~0.4.24\",\n        \"unpipe\": \"~1.0.0\"\n      },\n      \"dependencies\": {\n        \"iconv-lite\": {\n          \"version\": \"0.4.24\",\n          \"resolved\": \"https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz\",\n          \"integrity\": \"sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==\",\n          \"requires\": {\n            \"safer-buffer\": \">= 2.1.2 < 3\"\n          }\n        }\n      }\n    },\n    \"resolve-path\": {\n      \"version\": \"1.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz\",\n      \"integrity\": \"sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==\",\n      \"requires\": {\n        \"http-errors\": \"~1.6.2\",\n        \"path-is-absolute\": \"1.0.1\"\n      },\n      \"dependencies\": {\n        \"depd\": {\n          \"version\": \"1.1.2\",\n          \"resolved\": \"https://registry.npmjs.org/depd/-/depd-1.1.2.tgz\",\n          \"integrity\": \"sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==\"\n        },\n        \"http-errors\": {\n          \"version\": \"1.6.3\",\n          \"resolved\": \"https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz\",\n          \"integrity\": \"sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==\",\n          \"requires\": {\n            \"depd\": \"~1.1.2\",\n            \"inherits\": \"2.0.3\",\n            \"setprototypeof\": \"1.1.0\",\n            \"statuses\": \">= 1.4.0 < 2\"\n          }\n        },\n        \"inherits\": {\n          \"version\": \"2.0.3\",\n          \"resolved\": \"https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz\",\n          \"integrity\": \"sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==\"\n        },\n        \"setprototypeof\": {\n          \"version\": \"1.1.0\",\n          \"resolved\": \"https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz\",\n          \"integrity\": \"sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==\"\n        },\n        \"statuses\": {\n          \"version\": \"1.5.0\",\n          \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz\",\n          \"integrity\": \"sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==\"\n        }\n      }\n    },\n    \"safe-buffer\": {\n      \"version\": \"5.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz\",\n      \"integrity\": \"sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==\"\n    },\n    \"safe-regex-test\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz\",\n      \"integrity\": \"sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==\",\n      \"requires\": {\n        \"call-bound\": \"^1.0.2\",\n        \"es-errors\": \"^1.3.0\",\n        \"is-regex\": \"^1.2.1\"\n      }\n    },\n    \"safer-buffer\": {\n      \"version\": \"2.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz\",\n      \"integrity\": \"sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==\"\n    },\n    \"sax\": {\n      \"version\": \"1.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/sax/-/sax-1.5.0.tgz\",\n      \"integrity\": \"sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==\",\n      \"dev\": true\n    },\n    \"seedrandom\": {\n      \"version\": \"3.0.5\",\n      \"resolved\": \"https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz\",\n      \"integrity\": \"sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==\"\n    },\n    \"semver\": {\n      \"version\": \"7.7.4\",\n      \"resolved\": \"https://registry.npmjs.org/semver/-/semver-7.7.4.tgz\",\n      \"integrity\": \"sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==\"\n    },\n    \"setprototypeof\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz\",\n      \"integrity\": \"sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==\"\n    },\n    \"shebang-command\": {\n      \"version\": \"2.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz\",\n      \"integrity\": \"sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"shebang-regex\": \"^3.0.0\"\n      }\n    },\n    \"shebang-regex\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz\",\n      \"integrity\": \"sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==\",\n      \"dev\": true\n    },\n    \"side-channel\": {\n      \"version\": \"1.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz\",\n      \"integrity\": \"sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==\",\n      \"requires\": {\n        \"es-errors\": \"^1.3.0\",\n        \"object-inspect\": \"^1.13.3\",\n        \"side-channel-list\": \"^1.0.0\",\n        \"side-channel-map\": \"^1.0.1\",\n        \"side-channel-weakmap\": \"^1.0.2\"\n      }\n    },\n    \"side-channel-list\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz\",\n      \"integrity\": \"sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==\",\n      \"requires\": {\n        \"es-errors\": \"^1.3.0\",\n        \"object-inspect\": \"^1.13.3\"\n      }\n    },\n    \"side-channel-map\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz\",\n      \"integrity\": \"sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==\",\n      \"requires\": {\n        \"call-bound\": \"^1.0.2\",\n        \"es-errors\": \"^1.3.0\",\n        \"get-intrinsic\": \"^1.2.5\",\n        \"object-inspect\": \"^1.13.3\"\n      }\n    },\n    \"side-channel-weakmap\": {\n      \"version\": \"1.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz\",\n      \"integrity\": \"sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==\",\n      \"requires\": {\n        \"call-bound\": \"^1.0.2\",\n        \"es-errors\": \"^1.3.0\",\n        \"get-intrinsic\": \"^1.2.5\",\n        \"object-inspect\": \"^1.13.3\",\n        \"side-channel-map\": \"^1.0.1\"\n      }\n    },\n    \"smart-buffer\": {\n      \"version\": \"4.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz\",\n      \"integrity\": \"sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==\"\n    },\n    \"socks\": {\n      \"version\": \"2.8.7\",\n      \"resolved\": \"https://registry.npmjs.org/socks/-/socks-2.8.7.tgz\",\n      \"integrity\": \"sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==\",\n      \"requires\": {\n        \"ip-address\": \"^10.0.1\",\n        \"smart-buffer\": \"^4.2.0\"\n      }\n    },\n    \"source-map-js\": {\n      \"version\": \"1.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz\",\n      \"integrity\": \"sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==\",\n      \"dev\": true\n    },\n    \"sparse-bitfield\": {\n      \"version\": \"3.0.3\",\n      \"resolved\": \"https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz\",\n      \"integrity\": \"sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==\",\n      \"optional\": true,\n      \"requires\": {\n        \"memory-pager\": \"^1.0.2\"\n      }\n    },\n    \"sql.js\": {\n      \"version\": \"1.14.1\",\n      \"resolved\": \"https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz\",\n      \"integrity\": \"sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==\",\n      \"dev\": true\n    },\n    \"statuses\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz\",\n      \"integrity\": \"sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==\"\n    },\n    \"strnum\": {\n      \"version\": \"2.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz\",\n      \"integrity\": \"sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==\",\n      \"optional\": true\n    },\n    \"style-mod\": {\n      \"version\": \"4.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz\",\n      \"integrity\": \"sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==\",\n      \"dev\": true\n    },\n    \"svgo\": {\n      \"version\": \"3.3.3\",\n      \"resolved\": \"https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz\",\n      \"integrity\": \"sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==\",\n      \"dev\": true,\n      \"requires\": {\n        \"commander\": \"^7.2.0\",\n        \"css-select\": \"^5.1.0\",\n        \"css-tree\": \"^2.3.1\",\n        \"css-what\": \"^6.1.0\",\n        \"csso\": \"^5.0.5\",\n        \"picocolors\": \"^1.0.0\",\n        \"sax\": \"^1.5.0\"\n      }\n    },\n    \"tailwindcss\": {\n      \"version\": \"4.2.1\",\n      \"resolved\": \"https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz\",\n      \"integrity\": \"sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==\",\n      \"dev\": true\n    },\n    \"tapable\": {\n      \"version\": \"2.3.0\",\n      \"resolved\": \"https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz\",\n      \"integrity\": \"sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==\",\n      \"dev\": true\n    },\n    \"tinyglobby\": {\n      \"version\": \"0.2.15\",\n      \"resolved\": \"https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz\",\n      \"integrity\": \"sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"fdir\": \"^6.5.0\",\n        \"picomatch\": \"^4.0.3\"\n      },\n      \"dependencies\": {\n        \"fdir\": {\n          \"version\": \"6.5.0\",\n          \"resolved\": \"https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz\",\n          \"integrity\": \"sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==\",\n          \"dev\": true,\n          \"requires\": {}\n        },\n        \"picomatch\": {\n          \"version\": \"4.0.3\",\n          \"resolved\": \"https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz\",\n          \"integrity\": \"sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==\",\n          \"dev\": true\n        }\n      }\n    },\n    \"toidentifier\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz\",\n      \"integrity\": \"sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==\"\n    },\n    \"tr46\": {\n      \"version\": \"3.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz\",\n      \"integrity\": \"sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==\",\n      \"requires\": {\n        \"punycode\": \"^2.1.1\"\n      }\n    },\n    \"tslib\": {\n      \"version\": \"2.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz\",\n      \"integrity\": \"sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==\",\n      \"optional\": true\n    },\n    \"tsscmp\": {\n      \"version\": \"1.0.6\",\n      \"resolved\": \"https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz\",\n      \"integrity\": \"sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==\"\n    },\n    \"type-check\": {\n      \"version\": \"0.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz\",\n      \"integrity\": \"sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==\",\n      \"dev\": true,\n      \"requires\": {\n        \"prelude-ls\": \"^1.2.1\"\n      }\n    },\n    \"type-is\": {\n      \"version\": \"1.6.18\",\n      \"resolved\": \"https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz\",\n      \"integrity\": \"sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==\",\n      \"requires\": {\n        \"media-typer\": \"0.3.0\",\n        \"mime-types\": \"~2.1.24\"\n      }\n    },\n    \"typescript\": {\n      \"version\": \"5.9.3\",\n      \"resolved\": \"https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz\",\n      \"integrity\": \"sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==\",\n      \"dev\": true\n    },\n    \"typescript-eslint\": {\n      \"version\": \"8.56.1\",\n      \"resolved\": \"https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz\",\n      \"integrity\": \"sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==\",\n      \"dev\": true,\n      \"requires\": {\n        \"@typescript-eslint/eslint-plugin\": \"8.56.1\",\n        \"@typescript-eslint/parser\": \"8.56.1\",\n        \"@typescript-eslint/typescript-estree\": \"8.56.1\",\n        \"@typescript-eslint/utils\": \"8.56.1\"\n      },\n      \"dependencies\": {\n        \"@typescript-eslint/eslint-plugin\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz\",\n          \"integrity\": \"sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==\",\n          \"dev\": true,\n          \"requires\": {\n            \"@eslint-community/regexpp\": \"^4.12.2\",\n            \"@typescript-eslint/scope-manager\": \"8.56.1\",\n            \"@typescript-eslint/type-utils\": \"8.56.1\",\n            \"@typescript-eslint/utils\": \"8.56.1\",\n            \"@typescript-eslint/visitor-keys\": \"8.56.1\",\n            \"ignore\": \"^7.0.5\",\n            \"natural-compare\": \"^1.4.0\",\n            \"ts-api-utils\": \"^2.4.0\"\n          }\n        },\n        \"@typescript-eslint/parser\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz\",\n          \"integrity\": \"sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"@typescript-eslint/scope-manager\": \"8.56.1\",\n            \"@typescript-eslint/types\": \"8.56.1\",\n            \"@typescript-eslint/typescript-estree\": \"8.56.1\",\n            \"@typescript-eslint/visitor-keys\": \"8.56.1\",\n            \"debug\": \"^4.4.3\"\n          }\n        },\n        \"@typescript-eslint/scope-manager\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz\",\n          \"integrity\": \"sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==\",\n          \"dev\": true,\n          \"requires\": {\n            \"@typescript-eslint/types\": \"8.56.1\",\n            \"@typescript-eslint/visitor-keys\": \"8.56.1\"\n          }\n        },\n        \"@typescript-eslint/type-utils\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz\",\n          \"integrity\": \"sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"@typescript-eslint/types\": \"8.56.1\",\n            \"@typescript-eslint/typescript-estree\": \"8.56.1\",\n            \"@typescript-eslint/utils\": \"8.56.1\",\n            \"debug\": \"^4.4.3\",\n            \"ts-api-utils\": \"^2.4.0\"\n          }\n        },\n        \"@typescript-eslint/types\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz\",\n          \"integrity\": \"sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==\",\n          \"dev\": true\n        },\n        \"@typescript-eslint/typescript-estree\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz\",\n          \"integrity\": \"sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"@typescript-eslint/project-service\": \"8.56.1\",\n            \"@typescript-eslint/tsconfig-utils\": \"8.56.1\",\n            \"@typescript-eslint/types\": \"8.56.1\",\n            \"@typescript-eslint/visitor-keys\": \"8.56.1\",\n            \"debug\": \"^4.4.3\",\n            \"minimatch\": \"^10.2.2\",\n            \"semver\": \"^7.7.3\",\n            \"tinyglobby\": \"^0.2.15\",\n            \"ts-api-utils\": \"^2.4.0\"\n          }\n        },\n        \"@typescript-eslint/utils\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz\",\n          \"integrity\": \"sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==\",\n          \"dev\": true,\n          \"requires\": {\n            \"@eslint-community/eslint-utils\": \"^4.9.1\",\n            \"@typescript-eslint/scope-manager\": \"8.56.1\",\n            \"@typescript-eslint/types\": \"8.56.1\",\n            \"@typescript-eslint/typescript-estree\": \"8.56.1\"\n          }\n        },\n        \"@typescript-eslint/visitor-keys\": {\n          \"version\": \"8.56.1\",\n          \"resolved\": \"https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz\",\n          \"integrity\": \"sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==\",\n          \"dev\": true,\n          \"requires\": {\n            \"@typescript-eslint/types\": \"8.56.1\",\n            \"eslint-visitor-keys\": \"^5.0.0\"\n          }\n        },\n        \"balanced-match\": {\n          \"version\": \"4.0.4\",\n          \"resolved\": \"https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz\",\n          \"integrity\": \"sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==\",\n          \"dev\": true\n        },\n        \"brace-expansion\": {\n          \"version\": \"5.0.4\",\n          \"resolved\": \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz\",\n          \"integrity\": \"sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"balanced-match\": \"^4.0.2\"\n          }\n        },\n        \"eslint-visitor-keys\": {\n          \"version\": \"5.0.1\",\n          \"resolved\": \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz\",\n          \"integrity\": \"sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==\",\n          \"dev\": true\n        },\n        \"ignore\": {\n          \"version\": \"7.0.5\",\n          \"resolved\": \"https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz\",\n          \"integrity\": \"sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==\",\n          \"dev\": true\n        },\n        \"minimatch\": {\n          \"version\": \"10.2.4\",\n          \"resolved\": \"https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz\",\n          \"integrity\": \"sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==\",\n          \"dev\": true,\n          \"requires\": {\n            \"brace-expansion\": \"^5.0.2\"\n          }\n        },\n        \"ts-api-utils\": {\n          \"version\": \"2.4.0\",\n          \"resolved\": \"https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz\",\n          \"integrity\": \"sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==\",\n          \"dev\": true,\n          \"requires\": {}\n        }\n      }\n    },\n    \"undici-types\": {\n      \"version\": \"7.18.2\",\n      \"resolved\": \"https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz\",\n      \"integrity\": \"sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==\"\n    },\n    \"unpipe\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz\",\n      \"integrity\": \"sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==\"\n    },\n    \"uri-js\": {\n      \"version\": \"4.4.1\",\n      \"resolved\": \"https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz\",\n      \"integrity\": \"sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==\",\n      \"dev\": true,\n      \"requires\": {\n        \"punycode\": \"^2.1.0\"\n      }\n    },\n    \"vary\": {\n      \"version\": \"1.1.2\",\n      \"resolved\": \"https://registry.npmjs.org/vary/-/vary-1.1.2.tgz\",\n      \"integrity\": \"sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==\"\n    },\n    \"w3c-keyname\": {\n      \"version\": \"2.2.8\",\n      \"resolved\": \"https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz\",\n      \"integrity\": \"sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==\",\n      \"dev\": true\n    },\n    \"webidl-conversions\": {\n      \"version\": \"7.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz\",\n      \"integrity\": \"sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==\"\n    },\n    \"whatwg-url\": {\n      \"version\": \"11.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz\",\n      \"integrity\": \"sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==\",\n      \"requires\": {\n        \"tr46\": \"^3.0.0\",\n        \"webidl-conversions\": \"^7.0.0\"\n      }\n    },\n    \"which\": {\n      \"version\": \"2.0.2\",\n      \"resolved\": \"https://registry.npmjs.org/which/-/which-2.0.2.tgz\",\n      \"integrity\": \"sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==\",\n      \"dev\": true,\n      \"requires\": {\n        \"isexe\": \"^2.0.0\"\n      }\n    },\n    \"word-wrap\": {\n      \"version\": \"1.2.5\",\n      \"resolved\": \"https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz\",\n      \"integrity\": \"sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==\",\n      \"dev\": true\n    },\n    \"yaml\": {\n      \"version\": \"1.10.2\",\n      \"resolved\": \"https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz\",\n      \"integrity\": \"sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==\",\n      \"dev\": true\n    },\n    \"ylru\": {\n      \"version\": \"1.4.0\",\n      \"resolved\": \"https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz\",\n      \"integrity\": \"sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==\"\n    },\n    \"yocto-queue\": {\n      \"version\": \"0.1.0\",\n      \"resolved\": \"https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz\",\n      \"integrity\": \"sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==\",\n      \"dev\": true\n    }\n  }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"genieacs\",\n  \"version\": \"1.3.0-dev\",\n  \"description\": \"A TR-069 Auto Configuration Server (ACS)\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/genieacs/genieacs.git\"\n  },\n  \"homepage\": \"https://genieacs.com\",\n  \"keywords\": [\n    \"TR-069\",\n    \"CWMP\",\n    \"ACS\"\n  ],\n  \"author\": {\n    \"name\": \"GenieACS Inc.\",\n    \"url\": \"https://genieacs.com\"\n  },\n  \"license\": \"AGPL-3.0\",\n  \"private\": true,\n  \"bin\": {\n    \"genieacs-cwmp\": \"bin/genieacs-cwmp\",\n    \"genieacs-fs\": \"bin/genieacs-fs\",\n    \"genieacs-nbi\": \"bin/genieacs-nbi\",\n    \"genieacs-ui\": \"bin/genieacs-ui\"\n  },\n  \"dependencies\": {\n    \"@breejs/later\": \"^4.2.0\",\n    \"@koa/bodyparser\": \"^5.1.2\",\n    \"@koa/router\": \"^13.1.1\",\n    \"bson\": \"^4.7.2\",\n    \"espresso-iisojs\": \"^1.0.8\",\n    \"iconv-lite\": \"^0.6.3\",\n    \"ipaddr.js\": \"^2.3.0\",\n    \"jsonwebtoken\": \"^9.0.3\",\n    \"koa\": \"^2.16.4\",\n    \"koa-compress\": \"^5.2.0\",\n    \"koa-jwt\": \"^4.0.3\",\n    \"koa-send\": \"^5.0.1\",\n    \"mongodb\": \"^4.16.0\",\n    \"seedrandom\": \"^3.0.5\"\n  },\n  \"devDependencies\": {\n    \"@codemirror/commands\": \"^6.10.2\",\n    \"@codemirror/lang-javascript\": \"^6.2.5\",\n    \"@codemirror/lang-yaml\": \"^6.1.2\",\n    \"@codemirror/language\": \"^6.12.2\",\n    \"@codemirror/state\": \"^6.5.4\",\n    \"@codemirror/view\": \"^6.39.16\",\n    \"@eslint/js\": \"^10.0.1\",\n    \"@tailwindcss/cli\": \"^4.2.1\",\n    \"@tailwindcss/forms\": \"^0.5.11\",\n    \"@types/jsonwebtoken\": \"^9.0.10\",\n    \"@types/koa\": \"^2.15.0\",\n    \"@types/koa-compress\": \"^4.0.7\",\n    \"@types/mithril\": \"^2.2.7\",\n    \"@types/node\": \"^25.3.5\",\n    \"@types/seedrandom\": \"^3.0.8\",\n    \"esbuild\": \"^0.27.4\",\n    \"eslint\": \"^10.0.3\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"globals\": \"^17.4.0\",\n    \"mithril\": \"^2.3.8\",\n    \"prettier\": \"^3.8.1\",\n    \"sql.js\": \"^1.14.1\",\n    \"svgo\": \"^3.3.3\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"typescript\": \"^5.9.3\",\n    \"typescript-eslint\": \"^8.56.1\",\n    \"yaml\": \"^1.10.2\"\n  },\n  \"engines\": {\n    \"node\": \">=12.13.0\"\n  },\n  \"scripts\": {\n    \"test\": \"esbuild build/test.ts --bundle --platform=node --target=node18 --packages=external | node && node --test --enable-source-maps test/*.js && rm test/*.js\",\n    \"lint\": \"esbuild build/lint.ts --bundle --platform=node --target=node16 --packages=external | node\",\n    \"build\": \"esbuild build/build.ts --bundle --platform=node --target=node12 --packages=external | node\"\n  }\n}\n"
  },
  {
    "path": "seed/bootstrap.js",
    "content": "const now = Date.now();\n\n// Clear cached data model to force a refresh\nclear(\"Device\", now);\nclear(\"InternetGatewayDevice\", now);\n"
  },
  {
    "path": "seed/datamodel-explorer.jsx",
    "content": "// Interactive explorer for browsing and searching device data model parameters.\n//\n// Attributes:\n//   device - Device object containing parameter data\n//\n// Example:\n//   <datamodel-explorer device={device} />\n\nconst device = node.attributes.device.get();\nconst deviceId = device[\"DeviceID.ID\"];\nconst taskCmd = new Signal.State(null);\nconst queryString = new Signal.State(\"\");\n\nconst allKeys = [];\nfor (const key of Object.keys(device)) {\n  if (key.includes(\":\")) {\n    if (key.endsWith(\":object\")) {\n      const baseKey = key.slice(0, -7);\n      const depth = baseKey.split(\".\").length - 1;\n      while (allKeys.length <= depth) allKeys.push([]);\n      allKeys[depth].push(baseKey);\n    }\n    continue;\n  }\n  const depth = key.split(\".\").length - 1;\n  while (allKeys.length <= depth) allKeys.push([]);\n  allKeys[depth].push(key);\n}\nconst flatKeys = allKeys.flat();\n\nconst renderRow = (row) => {\n  const writable = device[`${row}:writable`];\n  const object = device[`${row}:object`];\n  const isInstance = /\\.[0-9]+$/.test(row);\n\n  return (\n    <tr key={row}>\n      <td class=\"pl-4 pr-2 py-2 truncate\">\n        <span class=\"inline-block truncate max-w-full\">{row}</span>\n      </td>\n      <td class=\"pr-4 py-2 text-right flex justify-end\">\n        {!object && <parameter device={device} param={row} />}\n        {object && writable && (\n          <button\n            onclick={() =>\n              taskCmd.set({\n                name: isInstance ? \"deleteObject\" : \"addObject\",\n                device: deviceId,\n                objectName: row,\n              })\n            }\n            title={\n              isInstance ? \"Delete this instance\" : \"Create a new instance\"\n            }\n          >\n            <icon\n              name={isInstance ? \"delete-instance\" : \"add-instance\"}\n              class=\"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\"\n            />\n          </button>\n        )}\n        <button\n          onclick={() =>\n            taskCmd.set({\n              name: \"getParameterValues\",\n              device: deviceId,\n              parameterNames: [row],\n            })\n          }\n          title=\"Refresh tree\"\n        >\n          <icon\n            name=\"refresh\"\n            class=\"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\"\n          />\n        </button>\n      </td>\n    </tr>\n  );\n};\n\nconst explorer = new Signal.Computed(() => {\n  const query = queryString.get();\n  const regExp =\n    query &&\n    new RegExp(\n      query\n        .split(\" \")\n        .filter(Boolean)\n        .map((s) => s.replace(/[-[\\]/{}()*+?.\\\\^$|]/g, \"\\\\$&\"))\n        .join(\".*\"),\n      \"i\",\n    );\n\n  const filtered = regExp\n    ? flatKeys.filter((k) => {\n        const value = device[k];\n        if (!device[`${k}:object`] && !value) return false;\n        return regExp.test(value ? `${k} ${value}` : k);\n      })\n    : flatKeys;\n\n  const sorted = filtered.sort().slice(0, 100);\n  return (\n    <>\n      <div class=\"overflow-hidden\">\n        <div class=\"overflow-y-scroll h-96 shadow-inner\">\n          <table class=\"w-full table-fixed font-mono text-xs text-stone-900\">\n            <tbody class=\"divide-y divide-stone-200\">\n              {sorted.map(renderRow)}\n            </tbody>\n          </table>\n        </div>\n      </div>\n      <div class=\"text-stone-700 px-4 py-3 flex justify-between items-end\">\n        <span class=\"text-xs\">\n          Displaying <span class=\"font-medium\">{sorted.length}</span> of{\" \"}\n          <span class=\"font-medium\">{filtered.length}</span> parameters\n        </span>\n        <a\n          href={`api/devices/${encodeURIComponent(deviceId)}.csv`}\n          download=\"\"\n          class=\"text-cyan-700 hover:text-cyan-900 text-sm font-medium\"\n        >\n          Download\n        </a>\n      </div>\n    </>\n  );\n});\n\nlet debounceTimer = null;\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-task arg={taskCmd} />\n    <div class=\"bg-white shadow rounded-lg\">\n      <input\n        type=\"text\"\n        oninput={(e) => {\n          clearTimeout(debounceTimer);\n          debounceTimer = setTimeout(\n            () => queryString.set(e.target.value),\n            500,\n          );\n        }}\n        placeholder=\"Search parameters\"\n        class=\"appearance-none border-0 block w-full px-4 py-3 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 text-sm rounded-t-lg font-mono focus:ring-2\"\n      />\n      {explorer}\n    </div>\n  </>\n);\n"
  },
  {
    "path": "seed/default.js",
    "content": "const hourly = Date.now(3600000);\n\n// Refresh basic parameters hourly\ndeclare(\"InternetGatewayDevice.DeviceInfo.HardwareVersion\", {\n  path: hourly,\n  value: hourly,\n});\ndeclare(\"InternetGatewayDevice.DeviceInfo.SoftwareVersion\", {\n  path: hourly,\n  value: hourly,\n});\ndeclare(\n  \"InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress\",\n  { path: hourly, value: hourly },\n);\ndeclare(\n  \"InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.ExternalIPAddress\",\n  { path: hourly, value: hourly },\n);\ndeclare(\"InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.SSID\", {\n  path: hourly,\n  value: hourly,\n});\n// Don't refresh password field periodically because CPEs always report blank passowrds for security reasons\ndeclare(\"InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.KeyPassphrase\", {\n  path: hourly,\n  value: 1,\n});\ndeclare(\"InternetGatewayDevice.LANDevice.*.Hosts.Host.*.HostName\", {\n  path: hourly,\n  value: hourly,\n});\ndeclare(\"InternetGatewayDevice.LANDevice.*.Hosts.Host.*.IPAddress\", {\n  path: hourly,\n  value: hourly,\n});\ndeclare(\"InternetGatewayDevice.LANDevice.*.Hosts.Host.*.MACAddress\", {\n  path: hourly,\n  value: hourly,\n});\n"
  },
  {
    "path": "seed/device-page-tr098.jsx",
    "content": "// Device page for TR-098 (InternetGatewayDevice) data model.\n//\n// Displays device information, parameters, LAN hosts, faults, and data model.\n// Customize the 'parameters' array below to change displayed fields.\n//\n// Attributes:\n//   device - Device object from the parent router\n\nconst device = node.attributes.device.get();\nconst deviceId = device[\"DeviceID.ID\"];\nconst taskCmd = new Signal.State(null);\nconst deviceFaults = new Signal.State(null);\nconst delCmd = new Signal.State(null);\nconst delStatus = new Signal.State(null);\n\nconst delMessage = new Signal.Computed(() => {\n  const s = delStatus.get();\n  if (s === true) return { type: \"success\", message: \"Deleted successfully\" };\n  if (s instanceof Error) return { type: \"error\", message: s.message };\n  return null;\n});\n\nconst pingResult = new Signal.State(null);\nconst pingDisplay = new Signal.Computed(() => {\n  const r = pingResult.get();\n  if (r == null) return null;\n  if (r instanceof Error) return \"Error!\";\n  if (typeof r === \"number\") return `${Math.trunc(r)} ms`;\n  return \"Unreachable\";\n});\n\nconst connectionUrl =\n  device[\"InternetGatewayDevice.ManagementServer.ConnectionRequestURL\"];\nconst hostIp = connectionUrl ? new URL(connectionUrl).hostname : null;\n\n// Device parameters to display\nconst parameters = [\n  { label: \"Serial number\", param: \"DeviceID.SerialNumber\" },\n  { label: \"Product class\", param: \"DeviceID.ProductClass\" },\n  { label: \"OUI\", param: \"DeviceID.OUI\" },\n  { label: \"Manufacturer\", param: \"DeviceID.Manufacturer\" },\n  {\n    label: \"Hardware version\",\n    param: \"InternetGatewayDevice.DeviceInfo.HardwareVersion\",\n  },\n  {\n    label: \"Software version\",\n    param: \"InternetGatewayDevice.DeviceInfo.SoftwareVersion\",\n  },\n  {\n    label: \"MAC\",\n    param:\n      \"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress\",\n  },\n  {\n    label: \"IP\",\n    param:\n      \"InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress\",\n  },\n  {\n    label: \"WLAN SSID\",\n    param: \"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID\",\n  },\n  {\n    label: \"WLAN passphrase\",\n    param:\n      \"InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase\",\n  },\n];\n\nconst hostsRoot = \"InternetGatewayDevice.LANDevice.1.Hosts.Host\";\nconst hostsColumns = [\n  { label: \"Host name\", param: \"HostName\" },\n  { label: \"IP\", param: \"IPAddress\" },\n  { label: \"MAC\", param: \"MACAddress\" },\n];\n\n// Parameters to refresh when summoning the device\nconst summonParams = [\n  ...parameters.map((p) => p.param).filter((p) => !p.startsWith(\"DeviceID.\")),\n  ...hostsColumns.map((c) => `${hostsRoot}.*.${c.param}`),\n];\n\nconst parameterRows = parameters\n  .filter(({ param }) => device[param])\n  .map(({ label, param }) => (\n    <tr class=\"border-b border-stone-200\">\n      <th class=\"text-sm font-medium text-stone-500 text-left px-6 py-3\">\n        {label}\n      </th>\n      <td class=\"text-sm text-stone-900 px-6 py-3\">\n        <parameter device={device} param={param} />\n      </td>\n    </tr>\n  ));\n\nconst FIVE_MINUTES = 5 * 60 * 1000;\nconst ONE_DAY = 24 * 60 * 60 * 1000;\n\nconst informTime = device[\"Events.Inform\"];\nconst now = Date.now();\nconst [onlineStatus, statusColor] =\n  informTime > now - FIVE_MINUTES\n    ? [\"Online\", \"#31a354\"]\n    : informTime > now - FIVE_MINUTES - ONE_DAY\n      ? [\"Past 24 Hours\", \"#a1d99b\"]\n      : [\"Others\", \"#e5f5e0\"];\n\nconst faultsTable = new Signal.Computed(() => {\n  const faults = deviceFaults.get();\n  if (!faults?.length)\n    return (\n      <tr>\n        <td\n          class=\"bg-stripes text-sm font-medium text-center text-stone-500 p-4\"\n          colspan=\"7\"\n        >\n          No faults\n        </td>\n      </tr>\n    );\n  return faults.map((f) => {\n    const yamlOut = new Signal.State(\"\");\n    return (\n      <tr key={f._id}>\n        <td class=\"whitespace-nowrap pl-6 pr-3 py-4 text-sm text-stone-900\">\n          {f.channel}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          {f.code}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          <span\n            class=\"inline-block truncate decoration-dotted max-w-xs\"\n            onmouseover={(e) => {\n              e.target.title = f.message;\n            }}\n          >\n            {f.message}\n          </span>\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          <do-yaml-stringify arg={f.detail} res={yamlOut} />\n          <span\n            class=\"inline-block truncate decoration-dotted max-w-xs cursor-pointer hover:underline\"\n            onmouseover={(e) => {\n              e.target.title = e.target.textContent;\n            }}\n          >\n            {yamlOut}\n          </span>\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          {f.retries}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          {new Date(f.timestamp).toLocaleString()}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          <button\n            class=\"text-cyan-700 hover:text-cyan-900 font-medium\"\n            onclick={() => delCmd.set({ resource: \"faults\", id: f._id })}\n          >\n            Delete\n          </button>\n        </td>\n      </tr>\n    );\n  });\n});\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-task arg={taskCmd} />\n    <do-delete arg={delCmd} res={delStatus} />\n    <do-notify arg={delMessage} />\n    <div class=\"device-page\">\n      <h1>{deviceId}</h1>\n      <tags device={device} writable={true} />\n      <do-ping arg={hostIp} res={pingResult} />\n      <div class=\"text-sm my-4 px-1\">\n        <span class=\"font-medium text-stone-500\">Pinging {hostIp}: </span>\n        {pingDisplay}\n      </div>\n      <table class=\"table-auto bg-white shadow rounded-lg divide-y divide-stone-200 w-max\">\n        <tbody>\n          <tr class=\"border-b border-stone-200\">\n            <th class=\"text-sm font-medium text-stone-500 text-left px-6 py-3\">\n              Last inform\n            </th>\n            <td class=\"text-sm text-stone-900 px-6 py-3\">\n              <span class=\"inform\">\n                <parameter device={device} param=\"Events.Inform\" />\n                <svg\n                  class=\"inline\"\n                  width=\"1em\"\n                  height=\"1em\"\n                  style=\"margin: 0 0.2em 0.2em\"\n                >\n                  <circle\n                    class=\"stroke-stone-200 stroke-1\"\n                    cx=\"0.5em\"\n                    cy=\"0.5em\"\n                    r=\"0.4em\"\n                    fill={statusColor}\n                  />\n                </svg>\n                {onlineStatus}\n                <summon-button deviceId={deviceId} params={summonParams} />\n              </span>\n            </td>\n          </tr>\n          {parameterRows}\n        </tbody>\n      </table>\n      <h2>LAN Hosts</h2>\n      <instance-table root={hostsRoot} device={device}>\n        {hostsColumns.map((c) => (\n          <param label={c.label} param={c.param} />\n        ))}\n      </instance-table>\n      <do-fetch\n        arg={{\n          resource: \"faults\",\n          filter: `_id > '${deviceId}:' AND _id < '${deviceId}:\\xff'`,\n        }}\n        res={deviceFaults}\n      />\n      <h2>Faults</h2>\n      <div class=\"shadow overflow-hidden rounded-lg w-max\">\n        <table class=\"divide-y divide-stone-200\">\n          <thead class=\"bg-stone-50\">\n            <tr>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 pl-6 pr-3\">\n                Channel\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Code\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Message\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Detail\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Retries\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Timestamp\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\"></th>\n            </tr>\n          </thead>\n          <tbody class=\"divide-y divide-stone-200 bg-white\">\n            {faultsTable}\n          </tbody>\n        </table>\n      </div>\n      <h2>Data model</h2>\n      <datamodel-explorer device={device} />\n      <div class=\"space-x-3 mt-4\">\n        {[\n          {\n            label: \"Reboot\",\n            title: \"Reboot device\",\n            task: { name: \"reboot\", device: deviceId },\n          },\n          {\n            label: \"Reset\",\n            title: \"Factory reset device\",\n            task: { name: \"factoryReset\", device: deviceId },\n          },\n          {\n            label: \"Push file\",\n            title: \"Push a firmware or config file\",\n            task: { name: \"download\", devices: [deviceId] },\n          },\n          {\n            label: \"Delete\",\n            title: \"Delete device\",\n            action: () => {\n              if (confirm(`Delete device ${deviceId}?`))\n                delCmd.set({ resource: \"devices\", id: deviceId });\n            },\n          },\n        ].map(({ label, title, task: t, action }) => (\n          <button\n            onclick={() => (action ? action() : taskCmd.set(t))}\n            title={title}\n            class=\"px-4 py-2 border border-stone-300 shadow-sm text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            {label}\n          </button>\n        ))}\n      </div>\n    </div>\n  </>\n);\n"
  },
  {
    "path": "seed/device-page-tr181.jsx",
    "content": "// Device page for TR-181 (Device:2) data model.\n//\n// Displays device information, parameters, LAN hosts, faults, and data model.\n// Customize the 'parameters' array below to change displayed fields.\n//\n// Attributes:\n//   device - Device object from the parent router\n\nconst device = node.attributes.device.get();\nconst deviceId = device[\"DeviceID.ID\"];\nconst taskCmd = new Signal.State(null);\nconst deviceFaults = new Signal.State(null);\nconst delCmd = new Signal.State(null);\nconst delStatus = new Signal.State(null);\n\nconst delMessage = new Signal.Computed(() => {\n  const s = delStatus.get();\n  if (s === true) return { type: \"success\", message: \"Deleted successfully\" };\n  if (s instanceof Error) return { type: \"error\", message: s.message };\n  return null;\n});\n\nconst pingResult = new Signal.State(null);\nconst pingDisplay = new Signal.Computed(() => {\n  const r = pingResult.get();\n  if (r == null) return null;\n  if (r instanceof Error) return \"Error!\";\n  if (typeof r === \"number\") return `${Math.trunc(r)} ms`;\n  return \"Unreachable\";\n});\n\nconst connectionUrl = device[\"Device.ManagementServer.ConnectionRequestURL\"];\nconst hostIp = connectionUrl ? new URL(connectionUrl).hostname : null;\n\n// Device parameters to display\nconst parameters = [\n  { label: \"Serial number\", param: \"DeviceID.SerialNumber\" },\n  { label: \"Product class\", param: \"DeviceID.ProductClass\" },\n  { label: \"OUI\", param: \"DeviceID.OUI\" },\n  { label: \"Manufacturer\", param: \"DeviceID.Manufacturer\" },\n  {\n    label: \"Hardware version\",\n    param: \"Device.DeviceInfo.HardwareVersion\",\n  },\n  {\n    label: \"Software version\",\n    param: \"Device.DeviceInfo.SoftwareVersion\",\n  },\n  {\n    label: \"MAC\",\n    param: \"Device.Ethernet.Interface.1.MACAddress\",\n  },\n  {\n    label: \"IP\",\n    param: \"Device.IP.Interface.1.IPv4Address.1.IPAddress\",\n  },\n  {\n    label: \"WLAN SSID\",\n    param: \"Device.WiFi.SSID.1.SSID\",\n  },\n  {\n    label: \"WLAN passphrase\",\n    param: \"Device.WiFi.AccessPoint.1.Security.KeyPassphrase\",\n  },\n];\n\nconst hostsRoot = \"Device.Hosts.Host\";\nconst hostsColumns = [\n  { label: \"Host name\", param: \"HostName\" },\n  { label: \"IP\", param: \"IPAddress\" },\n  { label: \"MAC\", param: \"PhysAddress\" },\n];\n\n// Parameters to refresh when summoning the device\nconst summonParams = [\n  ...parameters.map((p) => p.param).filter((p) => !p.startsWith(\"DeviceID.\")),\n  ...hostsColumns.map((c) => `${hostsRoot}.*.${c.param}`),\n];\n\nconst parameterRows = parameters\n  .filter(({ param }) => device[param])\n  .map(({ label, param }) => (\n    <tr class=\"border-b border-stone-200\">\n      <th class=\"text-sm font-medium text-stone-500 text-left px-6 py-3\">\n        {label}\n      </th>\n      <td class=\"text-sm text-stone-900 px-6 py-3\">\n        <parameter device={device} param={param} />\n      </td>\n    </tr>\n  ));\n\nconst FIVE_MINUTES = 5 * 60 * 1000;\nconst ONE_DAY = 24 * 60 * 60 * 1000;\n\nconst informTime = device[\"Events.Inform\"];\nconst now = Date.now();\nconst [onlineStatus, statusColor] =\n  informTime > now - FIVE_MINUTES\n    ? [\"Online\", \"#31a354\"]\n    : informTime > now - FIVE_MINUTES - ONE_DAY\n      ? [\"Past 24 Hours\", \"#a1d99b\"]\n      : [\"Others\", \"#e5f5e0\"];\n\nconst faultsTable = new Signal.Computed(() => {\n  const faults = deviceFaults.get();\n  if (!faults?.length)\n    return (\n      <tr>\n        <td\n          class=\"bg-stripes text-sm font-medium text-center text-stone-500 p-4\"\n          colspan=\"7\"\n        >\n          No faults\n        </td>\n      </tr>\n    );\n  return faults.map((f) => {\n    const yamlOut = new Signal.State(\"\");\n    return (\n      <tr key={f._id}>\n        <td class=\"whitespace-nowrap pl-6 pr-3 py-4 text-sm text-stone-900\">\n          {f.channel}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          {f.code}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          <span\n            class=\"inline-block truncate decoration-dotted max-w-xs\"\n            onmouseover={(e) => {\n              e.target.title = f.message;\n            }}\n          >\n            {f.message}\n          </span>\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          <do-yaml-stringify arg={f.detail} res={yamlOut} />\n          <span\n            class=\"inline-block truncate decoration-dotted max-w-xs cursor-pointer hover:underline\"\n            onmouseover={(e) => {\n              e.target.title = e.target.textContent;\n            }}\n          >\n            {yamlOut}\n          </span>\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          {f.retries}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          {new Date(f.timestamp).toLocaleString()}\n        </td>\n        <td class=\"whitespace-nowrap px-3 py-4 text-sm text-stone-900\">\n          <button\n            class=\"text-cyan-700 hover:text-cyan-900 font-medium\"\n            onclick={() => delCmd.set({ resource: \"faults\", id: f._id })}\n          >\n            Delete\n          </button>\n        </td>\n      </tr>\n    );\n  });\n});\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-task arg={taskCmd} />\n    <do-delete arg={delCmd} res={delStatus} />\n    <do-notify arg={delMessage} />\n    <div class=\"device-page\">\n      <h1>{deviceId}</h1>\n      <tags device={device} writable={true} />\n      <do-ping arg={hostIp} res={pingResult} />\n      <div class=\"text-sm my-4 px-1\">\n        <span class=\"font-medium text-stone-500\">Pinging {hostIp}: </span>\n        {pingDisplay}\n      </div>\n      <table class=\"table-auto bg-white shadow rounded-lg divide-y divide-stone-200 w-max\">\n        <tbody>\n          <tr class=\"border-b border-stone-200\">\n            <th class=\"text-sm font-medium text-stone-500 text-left px-6 py-3\">\n              Last inform\n            </th>\n            <td class=\"text-sm text-stone-900 px-6 py-3\">\n              <span class=\"inform\">\n                <parameter device={device} param=\"Events.Inform\" />\n                <svg\n                  class=\"inline\"\n                  width=\"1em\"\n                  height=\"1em\"\n                  style=\"margin: 0 0.2em 0.2em\"\n                >\n                  <circle\n                    class=\"stroke-stone-200 stroke-1\"\n                    cx=\"0.5em\"\n                    cy=\"0.5em\"\n                    r=\"0.4em\"\n                    fill={statusColor}\n                  />\n                </svg>\n                {onlineStatus}\n                <summon-button deviceId={deviceId} params={summonParams} />\n              </span>\n            </td>\n          </tr>\n          {parameterRows}\n        </tbody>\n      </table>\n      <h2>LAN Hosts</h2>\n      <instance-table root={hostsRoot} device={device}>\n        {hostsColumns.map((c) => (\n          <param label={c.label} param={c.param} />\n        ))}\n      </instance-table>\n      <do-fetch\n        arg={{\n          resource: \"faults\",\n          filter: `_id > '${deviceId}:' AND _id < '${deviceId}:\\xff'`,\n        }}\n        res={deviceFaults}\n      />\n      <h2>Faults</h2>\n      <div class=\"shadow overflow-hidden rounded-lg w-max\">\n        <table class=\"divide-y divide-stone-200\">\n          <thead class=\"bg-stone-50\">\n            <tr>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 pl-6 pr-3\">\n                Channel\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Code\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Message\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Detail\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Retries\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\">\n                Timestamp\n              </th>\n              <th class=\"py-3.5 text-left text-sm font-semibold text-stone-500 px-3\"></th>\n            </tr>\n          </thead>\n          <tbody class=\"divide-y divide-stone-200 bg-white\">\n            {faultsTable}\n          </tbody>\n        </table>\n      </div>\n      <h2>Data model</h2>\n      <datamodel-explorer device={device} />\n      <div class=\"space-x-3 mt-4\">\n        {[\n          {\n            label: \"Reboot\",\n            title: \"Reboot device\",\n            task: { name: \"reboot\", device: deviceId },\n          },\n          {\n            label: \"Reset\",\n            title: \"Factory reset device\",\n            task: { name: \"factoryReset\", device: deviceId },\n          },\n          {\n            label: \"Push file\",\n            title: \"Push a firmware or config file\",\n            task: { name: \"download\", devices: [deviceId] },\n          },\n          {\n            label: \"Delete\",\n            title: \"Delete device\",\n            action: () => {\n              if (confirm(`Delete device ${deviceId}?`))\n                delCmd.set({ resource: \"devices\", id: deviceId });\n            },\n          },\n        ].map(({ label, title, task: t, action }) => (\n          <button\n            onclick={() => (action ? action() : taskCmd.set(t))}\n            title={title}\n            class=\"px-4 py-2 border border-stone-300 shadow-sm text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\"\n          >\n            {label}\n          </button>\n        ))}\n      </div>\n    </div>\n  </>\n);\n"
  },
  {
    "path": "seed/device-page.jsx",
    "content": "// Router that delegates to the appropriate data model-specific device page.\n//\n// Automatically detects device data model (TR-098 or TR-181) and renders\n// the corresponding device page component.\n//\n// Attributes:\n//   deviceId - Device identifier string\n\nconst deviceId = node.attributes.deviceId.get();\nconst device = new Signal.State(null);\n\nconst page = new Signal.Computed(() => {\n  const dev = device.get()?.[0];\n  if (dev?.[\"Device:object\"]) {\n    return <device-page-tr181 device={dev} />;\n  } else if (dev?.[\"InternetGatewayDevice:object\"]) {\n    return <device-page-tr098 device={dev} />;\n  }\n});\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-fetch\n      arg={{ resource: \"devices\", filter: `DeviceID.ID = \"${deviceId}\"` }}\n      res={device}\n    />\n    {page}\n  </>\n);\n"
  },
  {
    "path": "seed/icon.jsx",
    "content": "// SVG icon library component.\n//\n// Attributes:\n//   name  - Icon name (see available icons below)\n//   class - CSS classes to apply to the SVG element\n//\n// Available icons:\n//   add, add-instance, close, delete-instance, edit, menu,\n//   refresh, remove, retry, sorted-asc, sorted-dsc, unsorted\n//\n// Example:\n//   <icon name=\"edit\" class=\"h-4 w-4 text-cyan-700\" />\n\nconst iconName = node.attributes.name.get();\n\nconst icons = {\n  \"add-instance\": [\n    <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" />,\n    <path d=\"M12 8v8M8 12h8\" />,\n  ],\n  add: [<path d=\"M12 5v14M5 12h14\" />],\n  close: [<path d=\"M6 18 18 6M6 6l12 12\" />],\n  \"delete-instance\": [\n    <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" />,\n    <path d=\"M8 12h8\" />,\n  ],\n  edit: [<path d=\"M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4z\" />],\n  menu: [<path d=\"M4 6h16M4 12h16M4 18h16\" />],\n  refresh: [\n    <path d=\"M23 4v6h-6\" />,\n    <path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\" />,\n  ],\n  remove: [<path d=\"M18 6 6 18M6 6l12 12\" />],\n  retry: [\n    <path d=\"m17 1 4 4-4 4\" />,\n    <path d=\"M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4\" />,\n    <path d=\"M21 13v2a4 4 0 0 1-4 4H3\" />,\n  ],\n  \"sorted-asc\": [<path d=\"M4 12h10M4 18h16M4 6h4\" />],\n  \"sorted-dsc\": [<path d=\"M4 12h10M4 6h16M4 18h4\" />],\n  unsorted: [<path d=\"M4 18h10M4 12h16M4 6h6\" />],\n};\n\nconst content = icons[iconName];\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nif (!content) return null;\n\nconst attrs = {\n  xmlns: \"http://www.w3.org/2000/svg\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  \"stroke-width\": \"2\",\n  class: node.attributes.class?.get(),\n  \"aria-hidden\": \"true\",\n  viewBox: \"0 0 24 24\",\n};\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn <svg {...attrs}>{content}</svg>;\n"
  },
  {
    "path": "seed/inform.js",
    "content": "// Device ID as user name\nconst username = declare(\"DeviceID.ID\", { value: 1 }).value[0];\n\n// Password will be fixed for a given device because Math.random() is seeded with device ID by default.\nconst password = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString(\n  36,\n);\n\nconst informInterval = 300;\n\n// Refresh values daily\nconst daily = Date.now(86400000);\n\n// Unique inform offset per device for better load distribution\nconst informTime = daily % 86400000;\n\ndeclare(\n  \"InternetGatewayDevice.ManagementServer.ConnectionRequestUsername\",\n  { value: daily },\n  { value: username },\n);\ndeclare(\n  \"InternetGatewayDevice.ManagementServer.ConnectionRequestPassword\",\n  { value: daily },\n  { value: password },\n);\ndeclare(\n  \"InternetGatewayDevice.ManagementServer.PeriodicInformEnable\",\n  { value: daily },\n  { value: true },\n);\ndeclare(\n  \"InternetGatewayDevice.ManagementServer.PeriodicInformInterval\",\n  { value: daily },\n  { value: informInterval },\n);\ndeclare(\n  \"InternetGatewayDevice.ManagementServer.PeriodicInformTime\",\n  { value: daily },\n  { value: informTime },\n);\n\ndeclare(\n  \"Device.ManagementServer.ConnectionRequestUsername\",\n  { value: daily },\n  { value: username },\n);\ndeclare(\n  \"Device.ManagementServer.ConnectionRequestPassword\",\n  { value: daily },\n  { value: password },\n);\ndeclare(\n  \"Device.ManagementServer.PeriodicInformEnable\",\n  { value: daily },\n  { value: true },\n);\ndeclare(\n  \"Device.ManagementServer.PeriodicInformInterval\",\n  { value: daily },\n  { value: informInterval },\n);\ndeclare(\n  \"Device.ManagementServer.PeriodicInformTime\",\n  { value: daily },\n  { value: informTime },\n);\n"
  },
  {
    "path": "seed/instance-table.jsx",
    "content": "// Table component for displaying object instances with configurable columns.\n//\n// Attributes:\n//   device - Device object containing parameter data\n//   root   - Object path to display instances from (e.g., \"Device.Hosts.Host\")\n//\n// Children:\n//   <param> elements defining columns:\n//     label - Column header text\n//     param - Parameter name relative to instance (e.g., \"HostName\")\n//\n// Example:\n//   <instance-table root=\"Device.Hosts.Host\" device={device}>\n//     <param label=\"Host name\" param=\"HostName\" />\n//     <param label=\"IP\" param=\"IPAddress\" />\n//   </instance-table>\n\nconst device = node.attributes.device.get();\nconst deviceId = device[\"DeviceID.ID\"];\nconst root = node.attributes.root.get();\nconst taskCmd = new Signal.State(null);\n\nconst columns = node.children\n  .map((c) => c.get())\n  .filter((c) => c.name === \"param\")\n  .map(({ attributes: { label, param } }) => ({ label, param }));\n\nconst instances = [\n  ...new Set(\n    Object.keys(device)\n      .filter((k) => k.startsWith(`${root}.`) && !k.includes(\":\"))\n      .map((k) => {\n        const dot = k.indexOf(\".\", root.length + 1);\n        return dot === -1 ? k : k.slice(0, dot);\n      }),\n  ),\n];\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-task arg={taskCmd} />\n    <div class=\"shadow overflow-hidden rounded-lg w-max\">\n      <table class=\"divide-y divide-stone-200\">\n        <thead class=\"bg-stone-50\">\n          <tr>\n            {columns.map(({ label }, i) => (\n              <th\n                class={`py-3.5 text-left text-sm font-semibold text-stone-500 ${i ? \"px-3\" : \"pl-6 pr-3\"}`}\n              >\n                {label}\n              </th>\n            ))}\n            <th class=\"pl-3\" />\n          </tr>\n        </thead>\n        <tbody class=\"bg-white divide-y divide-stone-200\">\n          {instances.length ? (\n            instances.map((inst) => (\n              <tr>\n                {columns.map(({ param }, i) => (\n                  <td\n                    class={`whitespace-nowrap py-4 text-sm text-stone-900 ${i ? \"px-3\" : \"pl-6 pr-3\"}`}\n                  >\n                    <parameter device={device} param={`${inst}.${param}`} />\n                  </td>\n                ))}\n                <td class=\"whitespace-nowrap pl-3 pr-6 py-4\">\n                  {device[`${inst}:writable`] && (\n                    <button\n                      onclick={() =>\n                        taskCmd.set({\n                          name: \"deleteObject\",\n                          device: deviceId,\n                          objectName: inst,\n                        })\n                      }\n                    >\n                      <icon\n                        name=\"delete-instance\"\n                        class=\"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\"\n                      />\n                    </button>\n                  )}\n                </td>\n              </tr>\n            ))\n          ) : (\n            <tr>\n              <td\n                class=\"bg-stripes text-sm font-medium text-center text-stone-500 p-4\"\n                colspan={columns.length + 1}\n              >\n                No instances\n              </td>\n            </tr>\n          )}\n          {device[`${root}:writable`] && (\n            <tr>\n              <td\n                class=\"whitespace-nowrap pl-3 pr-6 py-4 text-sm\"\n                colspan={columns.length + 1}\n              >\n                <button\n                  onclick={() =>\n                    taskCmd.set({\n                      name: \"addObject\",\n                      device: deviceId,\n                      objectName: root,\n                    })\n                  }\n                >\n                  <icon\n                    name=\"add-instance\"\n                    class=\"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\"\n                  />\n                </button>\n              </td>\n            </tr>\n          )}\n        </tbody>\n      </table>\n    </div>\n  </>\n);\n"
  },
  {
    "path": "seed/overview-page.jsx",
    "content": "// Dashboard page displaying device online status statistics.\n//\n// This is the default overview page shown on the main dashboard.\n// Customize the pie chart slices below to show different device groupings.\n\nconst FIVE_MINUTES = 5 * 60 * 1000;\nconst ONE_DAY = 24 * 60 * 60 * 1000;\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <div class=\"flex justify-center mt-5 mb-10 gap-x-10\">\n    <pie-chart label=\"Online Status\">\n      <slice\n        label=\"Online Now\"\n        color=\"#31a354\"\n        filter={`Events.Inform > NOW() - ${FIVE_MINUTES}`}\n      />\n      <slice\n        label=\"Past 24 Hours\"\n        color=\"#a1d99b\"\n        filter={`Events.Inform > NOW() - ${FIVE_MINUTES} - ${ONE_DAY} AND Events.Inform < NOW() - ${FIVE_MINUTES}`}\n      />\n      <slice\n        label=\"Others\"\n        color=\"#e5f5e0\"\n        filter={`Events.Inform < NOW() - ${FIVE_MINUTES} - ${ONE_DAY}`}\n      />\n    </pie-chart>\n  </div>\n);\n"
  },
  {
    "path": "seed/parameter.jsx",
    "content": "// Displays a device parameter value with optional inline editing.\n//\n// Attributes:\n//   device - Device object containing parameter data\n//   param  - Parameter path (e.g., \"DeviceID.SerialNumber\")\n//\n// Example:\n//   <parameter device={device} param=\"DeviceID.SerialNumber\" />\n\nconst device = node.attributes.device.get();\nconst deviceId = device[\"DeviceID.ID\"];\nconst param = node.attributes.param.get();\nconst taskCmd = new Signal.State(null);\n\nconst timeAgo = (ts) => {\n  const units = [\n    { label: \"year\", ms: 31536000000 },\n    { label: \"month\", ms: 2592000000 },\n    { label: \"day\", ms: 86400000 },\n    { label: \"hour\", ms: 3600000 },\n    { label: \"minute\", ms: 60000 },\n    { label: \"second\", ms: 1000 },\n  ];\n  let diff = Date.now() - ts;\n  const parts = [];\n  for (const { label, ms } of units) {\n    if (diff >= ms) {\n      const n = Math.floor(diff / ms);\n      diff %= ms;\n      parts.push(`${n} ${label}${n > 1 ? \"s\" : \"\"}`);\n      if (parts.length === 2) break;\n    }\n  }\n  return `${new Date(ts).toLocaleString()} (${parts.join(\" \")} ago)`;\n};\n\nconst value = device[param];\nconst type = device[`${param}:type`] || \"\";\nconst writable = device[`${param}:writable`];\nconst timestamp = device[`${param}:valueTimestamp`];\nconst displayValue =\n  typeof value === \"number\" && type === \"xsd:dateTime\"\n    ? new Date(value).toLocaleString()\n    : String(value);\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-task arg={taskCmd} />\n    <span\n      onmouseover={(e) => timestamp && (e.target.title = timeAgo(timestamp))}\n    >\n      {displayValue}\n    </span>\n    {writable && (\n      <button\n        onclick={() =>\n          taskCmd.set({\n            name: \"setParameterValues\",\n            devices: [deviceId],\n            parameterValues: [[param, value, type]],\n          })\n        }\n      >\n        <icon\n          name=\"edit\"\n          class=\"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\"\n        />\n      </button>\n    )}\n  </>\n);\n"
  },
  {
    "path": "seed/pie-chart.jsx",
    "content": "// Pie chart component that displays device counts by filter criteria.\n//\n// Attributes:\n//   label - Chart title displayed above the pie chart\n//\n// Children:\n//   <slice> elements with the following attributes:\n//     label  - Slice label shown in the legend\n//     color  - Fill color (e.g., \"#31a354\")\n//     filter - Device filter expression for counting devices\n//\n// Example:\n//   <pie-chart label=\"Status\">\n//     <slice label=\"Online\" color=\"#31a354\" filter=\"Events.Inform > NOW() - 300000\" />\n//     <slice label=\"Offline\" color=\"#e5f5e0\" filter=\"Events.Inform < NOW() - 300000\" />\n//   </pie-chart>\n\nconst getCoordinates = (percent) => {\n  const angle = 2 * Math.PI * percent;\n  const x = Math.cos(angle) * 100;\n  const y = Math.sin(angle) * 100;\n  return [x, y];\n};\n\nconst slices = node.children\n  .map((c) => c.get())\n  .filter((c) => c.name === \"slice\")\n  .map(({ attributes: { filter, label, color } }) => ({\n    count: new Signal.State(0),\n    filter,\n    label,\n    color,\n  }));\n\nconst chart = new Signal.Computed(() => {\n  let total = 0;\n  let cumulative = 0;\n\n  for (const slice of slices) total += slice.count.get();\n\n  const renderSlice = (slice) => {\n    const percent = (slice.count.get() || 0) / total;\n    const [startX, startY] = getCoordinates(cumulative);\n    cumulative += percent;\n    const [endX, endY] = getCoordinates(cumulative);\n    const largeArc = percent > 0.5 ? 1 : 0;\n    const d = `\n      M ${startX} ${startY}\n      A 100 100 0 ${largeArc} 1 ${endX} ${endY}\n      L 0 0\n      Z\n    `;\n\n    const midAngle = cumulative - percent / 2;\n    const percentageX = Math.cos(2 * Math.PI * midAngle) * 50;\n    const percentageY = Math.sin(2 * Math.PI * midAngle) * 50;\n\n    return (\n      <>\n        <path d={d} fill={slice.color} stroke=\"#fff\" strokeWidth=\"1\" />\n        <a\n          class=\"opacity-0 hover:opacity-100 focus-visible:opacity-100 outline-none\"\n          xlink:href={`#!/devices/?filter=${encodeURIComponent(slice.filter)}`}\n          target=\"__blank\"\n        >\n          <path class=\"stroke-cyan-500 stroke-1\" d={d} fill-opacity=\"0\" />\n          <text\n            class=\"opacity-40 font-medium fill-black\"\n            x={percentageX}\n            y={percentageY}\n            dominant-baseline=\"middle\"\n            text-anchor=\"middle\"\n          >\n            {Math.round(percent * 100)}%\n          </text>\n        </a>\n      </>\n    );\n  };\n\n  return (\n    <>\n      <svg\n        class=\"m-4\"\n        width=\"204px\"\n        height=\"204px\"\n        viewBox={\"-102 -102 204 204\"}\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        {slices.map(renderSlice)}\n      </svg>\n      <table class=\"table mt-8 text-sm\">\n        {slices.map((slice) => (\n          <tr>\n            <td>\n              <span\n                class=\"inline-block w-3 h-3 border border-stone-200 mr-1\"\n                style={{ \"background-color\": slice.color }}\n              />\n            </td>\n            <td class=\"w-full\">{slice.label}</td>\n            <td class=\"text-stone-500 text-right tabular-nums\">\n              {Math.round((slice.count.get() * 100) / total) || 0}%\n            </td>\n            <td class=\"text-right tabular-nums\">\n              <a\n                class=\"text-cyan-700 hover:text-cyan-900 font-medium ml-2\"\n                href={`#!/devices/?filter=${encodeURIComponent(slice.filter)}`}\n              >\n                {slice.count}\n              </a>\n            </td>\n          </tr>\n        ))}\n        <tr>\n          <td />\n          <td colspan=\"2\">Total</td>\n          <td class=\"text-right tabular-nums\">{total}</td>\n        </tr>\n      </table>\n    </>\n  );\n});\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <div class=\"p-4 bg-white shadow rounded-lg sm:p-6 sm:px-8\">\n    <h2 class=\"text-lg font-semibold text-stone-700 truncate mb-5 text-center\">\n      {node.attributes.label}\n    </h2>\n    {slices.map((s) => (\n      <do-count arg={{ resource: \"devices\", filter: s.filter }} res={s.count} />\n    ))}\n    {chart}\n  </div>\n);\n"
  },
  {
    "path": "seed/provisions.d.ts",
    "content": "interface Timestamps {\n  path?: number;\n  object?: number;\n  writable?: number;\n  value?: number;\n  notification?: number;\n  accessList?: number;\n}\n\ninterface Values {\n  path?: number | [number, number];\n  object?: boolean;\n  writable?: boolean;\n  value?: string | number | boolean | [string | number | boolean, string?];\n  notification?: number;\n  accessList?: string[];\n}\n\ninterface ParameterWrapper extends Iterable<ParameterWrapper> {\n  readonly path: string | undefined;\n  readonly size: number | undefined;\n  readonly object: 0 | 1 | undefined;\n  readonly writable: 0 | 1 | undefined;\n  readonly value: [string | number | boolean, string] | undefined;\n  readonly notification: number | undefined;\n  readonly accessList: string[] | undefined;\n}\n\ndeclare function declare(\n  path: string,\n  timestamps?: Timestamps | null,\n  values?: Values | null,\n): ParameterWrapper;\n\ndeclare function clear(\n  path: string,\n  timestamp: number,\n  attributes?: Timestamps,\n): void;\n\ndeclare function commit(): void;\n\ndeclare function ext(...args: unknown[]): unknown;\n\ndeclare function log(msg: string, meta?: Record<string, unknown>): void;\n\ndeclare const args: unknown[];\n\ninterface DateConstructor {\n  new (): Date;\n  new (\n    year: number,\n    monthIndex?: number,\n    day?: number,\n    hours?: number,\n    minutes?: number,\n    seconds?: number,\n    milliseconds?: number,\n  ): Date;\n  now(intervalOrCron?: number | string, variance?: number): number;\n  parse(dateString: string): number;\n  UTC(\n    year: number,\n    monthIndex?: number,\n    day?: number,\n    hours?: number,\n    minutes?: number,\n    seconds?: number,\n    milliseconds?: number,\n  ): number;\n}\n"
  },
  {
    "path": "seed/summon-button.jsx",
    "content": "// Button that initiates a device session and refreshes parameters.\n//\n// Attributes:\n//   deviceId - Device identifier string\n//   params   - Array of parameter paths to refresh (optional)\n//\n// Example:\n//   <summon-button deviceId={deviceId} params={[\"Device.DeviceInfo.SoftwareVersion\"]} />\n\nconst taskCmd = new Signal.State(null);\nconst status = new Signal.State(null);\nconst deviceId = node.attributes.deviceId.get();\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-task arg={taskCmd} res={status} />\n    <do-notify\n      arg={\n        new Signal.Computed(() => {\n          const s = status.get();\n          if (s === \"stale\" || s === \"fault\")\n            return { type: \"error\", message: `${deviceId}: ${s}` };\n          if (s === \"done\")\n            return { type: \"success\", message: `${deviceId}: Summoned` };\n          return null;\n        })\n      }\n    />\n    <button\n      onclick={() =>\n        taskCmd.set({\n          name: \"getParameterValues\",\n          commit: true,\n          parameterNames: node.attributes.params.get() ?? [],\n          device: deviceId,\n        })\n      }\n      disabled={\n        new Signal.Computed(() => [\"pending\", \"queued\"].includes(status.get()))\n      }\n      title=\"Initiate session and refresh basic parameters\"\n      class=\"px-2.5 py-1.5 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\"\n    >\n      Summon\n    </button>\n  </>\n);\n"
  },
  {
    "path": "seed/tags.jsx",
    "content": "// Displays and manages device tags with add/remove functionality.\n//\n// Attributes:\n//   device   - Device object containing tag data\n//   writable - Whether to show add/remove buttons (default: true)\n//\n// Example:\n//   <tags device={device} writable={true} />\n\nconst device = node.attributes.device.get();\nconst deviceId = device[\"DeviceID.ID\"];\nconst tagCmd = new Signal.State(null);\nconst writable = node.attributes.writable.get() ?? true;\n\nconst tags = Object.keys(device)\n  .filter((key) => key.startsWith(\"Tags.\") && !key.includes(\":\"))\n  .map((key) =>\n    decodeURIComponent(key.slice(5).replace(/0x(?=[0-9A-Z]{2})/g, \"%\")),\n  )\n  .sort();\n\n// @ts-expect-error: top-level return (script is wrapped in a function at runtime)\nreturn (\n  <>\n    <do-update-tags arg={tagCmd} />\n    {tags.map((t) => (\n      <span class=\"inline-flex items-center pl-3 pr-1 py-0.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 mr-2 ring-1 ring-yellow-200\">\n        {t}\n        {writable && (\n          <button\n            onclick={(e) => {\n              e.currentTarget.blur();\n              tagCmd.set({ deviceId, tags: { [t]: false } });\n            }}\n            title=\"Remove tag\"\n            class=\"flex-shrink-0 ml-0.5 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-none focus:bg-yellow-500 focus:text-white\"\n          >\n            <span class=\"sr-only\">Remove tag</span>\n            <icon name=\"remove\" class=\"inline h-4 w-4 text-yellow-400\" />\n          </button>\n        )}\n      </span>\n    ))}\n    {writable && (\n      <span class=\"inline-flex items-center pl-1 pr-1 py-0.5 rounded-full text-sm font-medium bg-yellow-50 ring-1 ring-yellow-200\">\n        <button\n          title=\"Add tag\"\n          onclick={(e) => {\n            e.currentTarget.blur();\n            const t = window.prompt(`Enter tag to assign to device:`);\n            if (t) tagCmd.set({ deviceId, tags: { [t]: true } });\n          }}\n          class=\"flex-shrink-0 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-none focus:bg-yellow-500 focus:text-white\"\n        >\n          <span class=\"sr-only\">Add tag</span>\n          <icon name=\"add\" class=\"inline h-4 w-4 text-yellow-400\" />\n        </button>\n      </span>\n    )}\n  </>\n);\n"
  },
  {
    "path": "seed/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"checkJs\": true,\n    \"noEmit\": true,\n    \"target\": \"ES2022\",\n    \"jsx\": \"react\",\n    \"jsxFactory\": \"h\",\n    \"jsxFragmentFactory\": \"Fragment\",\n    \"types\": [],\n    \"moduleDetection\": \"force\"\n  },\n  \"include\": [\"./*.js\", \"./*.jsx\", \"./*.d.ts\"]\n}\n"
  },
  {
    "path": "seed/views.d.ts",
    "content": "interface Signal<T = any> {\n  get(): T;\n}\n\ninterface StateSignal<T = any> extends Signal<T> {\n  set(value: T): void;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\ninterface ComputedSignal<T = any> extends Signal<T> {}\n\n// eslint-disable-next-line @typescript-eslint/no-empty-object-type\ninterface ConstSignal<T = any> extends Signal<T> {}\n\ninterface SignalConstructors {\n  State: new <T>(value: T) => StateSignal<T>;\n  Computed: new <T>(callback: () => T) => ComputedSignal<T>;\n  Const: new <T>(value: T) => ConstSignal<T>;\n}\n\ndeclare const Signal: SignalConstructors;\n\ntype ViewElement = ViewNode | string | number | Signal | ViewElement[];\n\ndeclare class ViewNode {\n  name: string | null;\n  attributes: Record<string, any>;\n  children: ViewElement[];\n}\n\ninterface SignalizedViewNode {\n  name: Signal<string | null>;\n  attributes: Record<string, Signal>;\n  children: Signal<ViewNode>[];\n}\n\ndeclare const node: SignalizedViewNode;\n\ndeclare function h(\n  name: string | null,\n  attributes: Record<string, unknown> | null,\n  ...children: ViewElement[]\n): ViewNode;\n\ndeclare const Fragment: null;\n"
  },
  {
    "path": "test/auth.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport { randomBytes } from \"node:crypto\";\nimport * as auth from \"../lib/auth.ts\";\n\nvoid test(\"digest\", () => {\n  const username = \"test\";\n  const password = \"test\";\n  const uri = \"/\";\n  const method = \"POST\";\n  const realm = \"GeniceACS\";\n  const nonce = randomBytes(16).toString(\"hex\");\n  const body = randomBytes(128).toString();\n\n  const challenges = [\n    `Digest realm=\"${realm}\",nonce=\"${nonce}\"`,\n    `Digest realm=\"${realm}\",nonce=\"${nonce}\",qop=\"auth\"`,\n    `Digest realm=\"${realm}\",nonce=\"${nonce}\",qop=\"auth-int\"`,\n  ];\n\n  for (const challenge of challenges) {\n    const wwwAuthHeader = auth.parseWwwAuthenticateHeader(challenge);\n    const solution = auth.solveDigest(\n      username,\n      password,\n      uri,\n      method,\n      body,\n      wwwAuthHeader,\n    );\n    const authHeader = auth.parseAuthorizationHeader(solution);\n    assert.strictEqual(\n      authHeader[\"response\"],\n      auth.digest(\n        username,\n        realm,\n        password,\n        nonce,\n        method,\n        uri,\n        authHeader[\"qop\"],\n        body,\n        authHeader[\"cnonce\"],\n        authHeader[\"nc\"],\n      ),\n    );\n  }\n});\n"
  },
  {
    "path": "test/db.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport { EJSON } from \"bson\";\nimport { Filter } from \"mongodb\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { convertOldPrecondition } from \"../lib/db/util.ts\";\nimport { toMongoQuery } from \"../lib/db/synth.ts\";\n\nvoid test(\"convertOldPrecondition\", () => {\n  const tests = [\n    [{}, \"TRUE\"],\n    [{ test: \"test\" }, 'test = \"test\"'],\n    [{ test: { $eq: \"test\" } }, 'test = \"test\"'],\n    [{ test: { $ne: \"test\" } }, 'test <> \"test\" OR test IS NULL'],\n    [{ test: { $gte: \"test\" } }, 'test >= \"test\"'],\n    [{ \"Tags.test\": true }, \"Tags.test IS NOT NULL\"],\n    [{ \"Tags.test\": false }, \"Tags.test IS NULL\"],\n    [{ \"Tags.test\": { $exists: true } }, \"Tags.test IS NOT NULL\"],\n    [{ \"Tags.test\": { $exists: false } }, \"Tags.test IS NULL\"],\n    [{ \"Tags.test\": { $ne: true } }, \"Tags.test IS NULL\"],\n    [{ \"Tags.test\": { $ne: false } }, \"Tags.test IS NOT NULL\"],\n    [{ \"Tags.test\": { $eq: true } }, \"Tags.test IS NOT NULL\"],\n    [{ \"Tags.test\": { $eq: false } }, \"Tags.test IS NULL\"],\n    [{ _tags: \"test\" }, \"Tags.test IS NOT NULL\"],\n    [{ _tags: { $ne: \"test\" } }, \"Tags.test IS NULL\"],\n    [{ _tags: { $eq: \"test\" } }, \"Tags.test IS NOT NULL\"],\n    [\n      { $and: [{ test: \"test\" }, { test: { $ne: \"test\" } }] },\n      'test = \"test\" AND (test <> \"test\" OR test IS NULL)',\n    ],\n    [{ test: \"test\", test2: \"test2\" }, 'test = \"test\" AND test2 = \"test2\"'],\n    [\n      { $or: [{ test: \"test\" }, { test: { $ne: \"test\" } }] },\n      'test = \"test\" OR test <> \"test\" OR test IS NULL',\n    ],\n    [\n      { test: { $gte: \"test1\", $ne: \"test2\" } },\n      'test >= \"test1\" AND (test <> \"test2\" OR test IS NULL)',\n    ],\n    [\n      { test: \"test\", test2: { $ne: \"test2\" } },\n      'test = \"test\" AND (test2 <> \"test2\" OR test2 IS NULL)',\n    ],\n  ];\n\n  const shouldFailTests = [\n    [{ test: { $gee: \"test\" } }, \"Operator $gee not supported\"],\n    [{ test: [] }, \"Invalid type\"],\n    [{ \"Tags.test\": { $gt: true } }, \"Invalid tag query\"],\n    [{ _tags: [] }, \"Invalid type\"],\n    [{ _tags: { $gt: \"test\" } }, \"Invalid tag query\"],\n    [{ $nor: [] }, \"Operator $nor not supported\"],\n  ] as [Filter<unknown>, string][];\n\n  for (const t of tests) {\n    assert.strictEqual(\n      convertOldPrecondition(t[0] as Record<string, unknown>).toString(),\n      t[1],\n    );\n  }\n\n  for (const t of shouldFailTests) {\n    const func = (): void => {\n      convertOldPrecondition(t[0]);\n    };\n    assert.throws(func, new Error(t[1]));\n  }\n});\n\nvoid test(\"toMongoQuery\", async () => {\n  const queries: [string, Filter<unknown> | false][] = [\n    [\"true\", {}],\n    [\"Tags.tag1 = true\", { _tags: { $eq: \"tag1\" } }],\n    [\"Tags.tag1 <> false\", { _tags: { $eq: \"tag1\" } }],\n    [\"Tags.tag1 IS NULL\", { _tags: { $ne: \"tag1\" } }],\n    [\"Tags.tag1 = 123\", false],\n    [\"Param1 = 'value1'\", { \"Param1._value\": { $eq: \"value1\" } }],\n    [\n      \"Param1 <> 'value1'\",\n      {\n        \"Param1._value\": { $ne: \"value1\" },\n        $and: [{ \"Param1._value\": { $ne: null } }],\n      },\n    ],\n    [\n      \"Param1 <> 1657844103524\",\n      {\n        \"Param1._value\": { $ne: 1657844103524 },\n        $and: [\n          {\n            \"Param1._value\": { $ne: { $date: \"2022-07-15T00:15:03.524Z\" } },\n          },\n          { \"Param1._value\": { $ne: null } },\n        ],\n      },\n    ],\n    [\n      \"Param1 = 1657844103524\",\n      {\n        $or: [\n          { \"Param1._value\": { $eq: { $date: \"2022-07-15T00:15:03.524Z\" } } },\n          { \"Param1._value\": { $eq: 1657844103524 } },\n        ],\n      },\n    ],\n    [\"Param1 > 'value'\", { \"Param1._value\": { $gt: \"value\" } }],\n    [\"Param1 IS NOT NULL\", { \"Param1._value\": { $ne: null } }],\n    [\n      \"Param1 LIKE 'value'\",\n      {\n        \"Param1._value\": {\n          $regularExpression: { options: \"s\", pattern: \"^value$\" },\n        },\n      },\n    ],\n    [\n      \"LOWER(Param1) LIKE 'value'\",\n      {\n        \"Param1._value\": {\n          $regularExpression: { options: \"is\", pattern: \"^value$\" },\n        },\n      },\n    ],\n    [\n      \"Param1 <> 'value2' OR NOT (Param2 = 'value1' OR Param1 < 'value2')\",\n      {\n        $or: [\n          {\n            \"Param2._value\": { $ne: null },\n            $and: [{ \"Param2._value\": { $ne: \"value1\" } }],\n            \"Param1._value\": { $eq: \"value2\" },\n          },\n          {\n            \"Param1._value\": { $ne: \"value2\" },\n            $and: [{ \"Param1._value\": { $ne: null } }],\n          },\n        ],\n      },\n    ],\n    [\n      \"Param1 <> 'value2' OR Param1 IS NULL\",\n      { \"Param1._value\": { $ne: \"value2\" } },\n    ],\n  ];\n\n  for (const [expStr, expect] of queries) {\n    const exp = Expression.parse(expStr);\n    let query = toMongoQuery(exp, \"devices\");\n    if (query) query = EJSON.serialize(query);\n    assert.deepStrictEqual(query, expect);\n  }\n\n  const failQueries: [any, string][] = [\n    [\"Param1 = Param2\", \"Right-hand operand must be a literal value\"],\n    [\"Param1 LIKE Param2\", \"Right-hand operand of 'LIKE' must be a string\"],\n    [\"NOW() = 1\", \"Left-hand operand must be a parameter\"],\n  ];\n\n  for (const [expStr, err] of failQueries) {\n    const exp = Expression.parse(expStr);\n    assert.throws(() => toMongoQuery(exp, \"devices\"), {\n      message: err,\n    });\n  }\n});\n"
  },
  {
    "path": "test/device.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport Path from \"../lib/common/path.ts\";\nimport * as device from \"../lib/device.ts\";\nimport PathSet from \"../lib/common/path-set.ts\";\nimport VersionedMap from \"../lib/versioned-map.ts\";\nimport { Attributes } from \"../lib/types.ts\";\n\nvoid test(\"getAliasDeclarations\", () => {\n  const path = Path.parse(\"a.[aa = 10 AND bb.[aaa = 100].cc = 1].b\");\n  const decs = device.getAliasDeclarations(path, 99);\n\n  const expected = [\"a.*.b\", \"a.*.aa\", \"a.*.bb.*.cc\", \"a.*.bb.*.aaa\"];\n\n  for (const [i, d] of decs.entries()) {\n    assert.strictEqual(d.path.toString(), expected[i]);\n    assert.strictEqual(d.pathGet, 99);\n    assert.strictEqual(d.pathSet, null);\n    assert.deepStrictEqual(d.attrGet, i ? { value: 99 } : null);\n    assert.strictEqual(d.attrSet, null);\n    assert.strictEqual(d.defer, true);\n  }\n});\n\nvoid test(\"unpack\", () => {\n  const now = Date.now();\n  const deviceData = {\n    paths: new PathSet(),\n    timestamps: new VersionedMap<Path, number>(),\n    attributes: new VersionedMap<Path, Attributes>(),\n    trackers: new Map<Path, { [name: string]: number }>(),\n    changes: new Set<string>(),\n  };\n\n  device.set(deviceData, \"a.1.b\", now, {\n    value: [now, [\"b\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.1.c\", now, {\n    value: [now, [\"c\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.1.a.1.a\", now, {\n    value: [now, [\"\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.1.a.1.b\", now, {\n    value: [now, [\"b1\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.1.a.1.c\", now, {\n    value: [now, [\"c1\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.1.a.2.a\", now, {\n    value: [now, [\"\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.1.a.2.b\", now, {\n    value: [now, [\"b2\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.1.a.2.c\", now, {\n    value: [now, [\"c2\", \"xsd:string\"]],\n  });\n\n  device.set(deviceData, \"a.2.b\", now, {\n    value: [now, [\"b\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.2.c\", now, {\n    value: [now, [\"c\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.2.a.1.a\", now, {\n    value: [now, [\"\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.2.a.1.b\", now, {\n    value: [now, [\"b1\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.2.a.1.c\", now, {\n    value: [now, [\"c1\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.2.a.2.a\", now, {\n    value: [now, [\"\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.2.a.2.b\", now, {\n    value: [now, [\"c1\", \"xsd:string\"]],\n  });\n  device.set(deviceData, \"a.2.a.2.c\", now, {\n    value: [now, [\"b1\", \"xsd:string\"]],\n  });\n\n  let unpacked: Path[];\n  unpacked = device.unpack(\n    deviceData,\n    Path.parse(\"a.[b='b' AND c='c'].a.[b='b1' AND c='c1'].a\"),\n  );\n  assert.strictEqual(unpacked.length, 2);\n  assert.strictEqual(unpacked[0].toString(), \"a.1.a.1.a\");\n  assert.strictEqual(unpacked[1].toString(), \"a.2.a.1.a\");\n\n  unpacked = device.unpack(\n    deviceData,\n    Path.parse(\"a.*.a.[b='c1' AND c='b1'].a\"),\n  );\n  assert.strictEqual(unpacked.length, 1);\n  assert.strictEqual(unpacked[0].toString(), \"a.2.a.2.a\");\n});\n"
  },
  {
    "path": "test/mocks/store.ts",
    "content": "// Mock implementation of ui/store.ts for testing (substituted via esbuild alias)\n\nimport Expression from \"../../lib/common/expression.ts\";\n\n// Provide window.clockSkew for reactive-store.ts in Node.js test environment\nif (typeof globalThis.window === \"undefined\") {\n  (globalThis as any).window = { clockSkew: 0 };\n} else {\n  (globalThis.window as any).clockSkew = 0;\n}\n\n// Clock skew is always 0 in tests\nexport function getClockSkew(): number {\n  return 0;\n}\n\n// Mock request handler type\ntype MockHandler = (options: XhrRequestOptions) => unknown | Promise<unknown>;\n\n// Store mock handlers\nconst mockHandlers: MockHandler[] = [];\n\n// Track all requests for test assertions\ninterface RequestRecord {\n  url: string;\n  method: string;\n  timestamp: number;\n}\nconst requestLog: RequestRecord[] = [];\n\n// Options type matching mithril's RequestOptions\ninterface XhrRequestOptions {\n  url: string;\n  method?: string;\n  body?: unknown;\n  extract?: (xhr: MockXhr) => unknown;\n  deserialize?: (text: string) => unknown;\n  background?: boolean;\n}\n\n// Mock XMLHttpRequest for extract functions\ninterface MockXhr {\n  status: number;\n  responseText: string;\n  getResponseHeader(name: string): string | null;\n}\n\nfunction parseQueryParams(url: string): Record<string, string> {\n  const params: Record<string, string> = {};\n  const queryStart = url.indexOf(\"?\");\n  if (queryStart === -1) return params;\n\n  const queryString = url.slice(queryStart + 1);\n  for (const pair of queryString.split(\"&\")) {\n    const [key, value] = pair.split(\"=\");\n    if (key && value !== undefined) {\n      params[decodeURIComponent(key)] = decodeURIComponent(value);\n    }\n  }\n  return params;\n}\n\nfunction evaluate(\n  exp: Expression,\n  obj: Record<string, unknown>,\n  timestamp: number,\n): Expression {\n  return exp.evaluate((e) => {\n    if (e instanceof Expression.Literal) return e;\n    else if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"NOW\") return new Expression.Literal(timestamp);\n    } else if (e instanceof Expression.Parameter && obj) {\n      let v = obj[e.path.toString()];\n      if (v == null) return new Expression.Literal(null);\n      if (typeof v === \"object\")\n        v = (v as Record<string, unknown>)[\"value\"]?.[0];\n      return new Expression.Literal(v as string | number | boolean | null);\n    }\n    return e;\n  });\n}\n\nfunction filterData(data: unknown[], filterStr: string | undefined): unknown[] {\n  if (!filterStr) return data;\n\n  const filterExpr = Expression.parse(filterStr);\n  if (filterExpr == null) return data;\n\n  const now = Date.now();\n  return data.filter((obj) => {\n    const result = evaluate(filterExpr, obj as Record<string, unknown>, now);\n    return result instanceof Expression.Literal && !!result.value;\n  });\n}\n\nexport async function xhrRequest(options: XhrRequestOptions): Promise<unknown> {\n  // Log the request\n  requestLog.push({\n    url: options.url,\n    method: options.method || \"GET\",\n    timestamp: Date.now(),\n  });\n\n  for (const handler of mockHandlers) {\n    const result = handler(options);\n    if (result !== undefined) {\n      return result instanceof Promise ? result : Promise.resolve(result);\n    }\n  }\n\n  // Default: return empty result based on method\n  if (options.method === \"HEAD\") {\n    if (options.extract) {\n      const mockXhr: MockXhr = {\n        status: 200,\n        responseText: \"\",\n        getResponseHeader: (name: string) => {\n          if (name.toLowerCase() === \"x-total-count\") return \"0\";\n          return null;\n        },\n      };\n      return options.extract(mockXhr);\n    }\n    return 0;\n  }\n\n  // GET returns empty array by default\n  return [];\n}\n\nexport function mockRegisterHandler(handler: MockHandler): void {\n  mockHandlers.push(handler);\n}\n\nexport function mockClearHandlers(): void {\n  mockHandlers.length = 0;\n  requestLog.length = 0;\n}\n\nexport function mockGetRequestLog(): RequestRecord[] {\n  return [...requestLog];\n}\n\nexport function mockClearRequestLog(): void {\n  requestLog.length = 0;\n}\n\nexport function mockUrlHandler(\n  urlPattern: string | RegExp,\n  response: unknown | ((options: XhrRequestOptions) => unknown),\n): MockHandler {\n  return (options: XhrRequestOptions) => {\n    const matches =\n      typeof urlPattern === \"string\"\n        ? options.url.includes(urlPattern)\n        : urlPattern.test(options.url);\n\n    if (matches) {\n      return typeof response === \"function\" ? response(options) : response;\n    }\n    return undefined;\n  };\n}\n\nexport function mockCountHandler(\n  urlPattern: string | RegExp,\n  data: unknown[],\n  delayMs = 0,\n): MockHandler {\n  return (options: XhrRequestOptions) => {\n    if (options.method !== \"HEAD\") return undefined;\n\n    const matches =\n      typeof urlPattern === \"string\"\n        ? options.url.includes(urlPattern)\n        : urlPattern.test(options.url);\n\n    if (matches && options.extract) {\n      const params = parseQueryParams(options.url);\n      const filtered = filterData(data, params.filter);\n      const count = filtered.length;\n\n      const mockXhr: MockXhr = {\n        status: 200,\n        responseText: \"\",\n        getResponseHeader: (name: string) => {\n          if (name.toLowerCase() === \"x-total-count\") return String(count);\n          return null;\n        },\n      };\n\n      if (delayMs > 0) {\n        return new Promise((resolve) => {\n          setTimeout(() => resolve(options.extract!(mockXhr)), delayMs);\n        });\n      }\n      return options.extract(mockXhr);\n    }\n    return undefined;\n  };\n}\n\nexport function mockFetchHandler(\n  urlPattern: string | RegExp,\n  data: unknown[],\n  delayMs = 0,\n): MockHandler {\n  return (options: XhrRequestOptions) => {\n    if (options.method && options.method !== \"GET\") return undefined;\n\n    const matches =\n      typeof urlPattern === \"string\"\n        ? options.url.includes(urlPattern)\n        : urlPattern.test(options.url);\n\n    if (matches) {\n      const params = parseQueryParams(options.url);\n      const filtered = filterData(data, params.filter);\n\n      if (delayMs > 0) {\n        return new Promise((resolve) => {\n          setTimeout(() => resolve(filtered), delayMs);\n        });\n      }\n      return filtered;\n    }\n    return undefined;\n  };\n}\n"
  },
  {
    "path": "test/pagination.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport initSqlJs from \"sql.js/dist/sql-asm.js\";\nimport {\n  bookmarkToExpression,\n  paginate,\n  toBookmark,\n} from \"../lib/common/expression/pagination.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { covers, minimize } from \"../lib/common/expression/synth.ts\";\n\nconst VALUES = [null, -1, false, \"a\"];\nconst PARAMS = [\"param1\", \"param2\"];\n\nlet db;\n\nasync function query(filter: string): Promise<{ id: string }[]> {\n  if (!db) {\n    const sql = await initSqlJs();\n    db = new sql.Database();\n\n    db.run(`CREATE TABLE test (id INTEGER PRIMARY KEY, ${PARAMS.join(\", \")})`);\n\n    const stmt = db.prepare(\n      `INSERT INTO test (${PARAMS.join(\", \")}) VALUES (${PARAMS.map(\n        () => \"?\",\n      ).join(\", \")})`,\n    );\n    const count = VALUES.length ** PARAMS.length;\n    for (let i = 0; i < count; ++i) {\n      const values: (boolean | number | string)[] = [];\n      for (let j = 0; j < PARAMS.length; ++j)\n        values.push(VALUES[Math.trunc(i / VALUES.length ** j) % VALUES.length]);\n      stmt.run(values);\n    }\n    stmt.free();\n  }\n\n  const res = db.exec(`SELECT * FROM test WHERE ${filter}`);\n  if (!res.length) return [];\n  return res[0].values.map((row) =>\n    Object.fromEntries(row.map((v, i) => [res[0].columns[i], v])),\n  );\n}\n\nfunction getAllSortOrders(columns: string[]): Array<Record<string, number>> {\n  const sortOrders: Array<Record<string, number>> = [];\n\n  function generateOrders(\n    remaining: string[],\n    current: Record<string, number>,\n  ): void {\n    if (remaining.length === 0) {\n      sortOrders.push({ ...current });\n      return;\n    }\n\n    for (const column of remaining) {\n      const newRemaining = remaining.filter((c) => c !== column);\n      current[column] = -1;\n      generateOrders(newRemaining, current);\n      current[column] = 1;\n      generateOrders(newRemaining, current);\n      delete current[column];\n    }\n  }\n\n  generateOrders(columns, {});\n  return sortOrders;\n}\n\nasync function testPaginate(\n  q1: Expression,\n  q2: Expression,\n  sort: Record<string, number>,\n): Promise<void> {\n  const orderBy = Object.entries(sort)\n    .map(([k, v]) => `${k} ${v > 0 ? \"ASC\" : \"DESC\"}`)\n    .join(\", \");\n\n  const allMatches = await query(`${q2.toString()} ORDER BY ${orderBy}`);\n  const [fulfilled, diff] = paginate(q1, q2, sort);\n  assert.ok(covers(q1, fulfilled));\n\n  const fulfilledMatches = await query(\n    `${fulfilled.toString()} ORDER BY ${orderBy}`,\n  );\n\n  const diffMatches = await query(`${diff.toString()} ORDER BY ${orderBy}`);\n  assert.deepStrictEqual(allMatches, [...fulfilledMatches, ...diffMatches]);\n\n  if (allMatches.length === fulfilledMatches.length) return;\n\n  const nextMatches = allMatches.slice(\n    fulfilledMatches.length,\n    fulfilledMatches.length + 1,\n  );\n  const bookmark = toBookmark(sort, nextMatches[nextMatches.length - 1]);\n  const bookmarkFilter = bookmarkToExpression(bookmark, sort);\n  const capped = Expression.and(q2, bookmarkFilter);\n\n  assert.ok(!covers(q1, capped));\n  const cappedMatches = await query(\n    `(${capped.toString()}) ORDER BY ${orderBy}`,\n  );\n  assert.deepStrictEqual(cappedMatches, [...fulfilledMatches, ...nextMatches]);\n  const min = minimize(Expression.or(q1, capped));\n  await testPaginate(min, q2, sort);\n}\n\nvoid test(\"paginate\", async () => {\n  const cases: [string, string][] = [[\"param2 < 'a'\", \"param1 >= 'a'\"]];\n  const params = [\"id\", ...PARAMS];\n  const sortOrders = getAllSortOrders(params);\n\n  for (const [q1, q2] of cases)\n    for (const sort of sortOrders) {\n      await testPaginate(Expression.parse(q1), Expression.parse(q2), sort);\n    }\n});\n"
  },
  {
    "path": "test/path-set.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport Path from \"../lib/common/path.ts\";\nimport PathSet from \"../lib/common/path-set.ts\";\n\nvoid test(\"add\", () => {\n  const pathSet = new PathSet();\n  pathSet.add(\"a\");\n  pathSet.add(\"a\");\n  assert.strictEqual(\n    pathSet.findCompat(Path.parse(\"a\"), true, true, 99).length,\n    1,\n  );\n});\n\nvoid test(\"get\", () => {\n  const pathSet = new PathSet();\n  pathSet.add(\"a.*\");\n  pathSet.add(\"a.a\");\n  pathSet.add(\"*.*\");\n\n  assert.strictEqual(pathSet.get(\"a.*\").toString(), \"a.*\");\n  assert.equal(pathSet.get(\"*.a\"), null);\n});\n\nvoid test(\"find\", () => {\n  const pathSet = new PathSet();\n  pathSet.add(\"a\");\n  pathSet.add(\"a.*\");\n  pathSet.add(\"a.a\");\n  pathSet.add(\"*.a\");\n  pathSet.add(\"*.*\");\n\n  assert.deepStrictEqual(\n    pathSet.findCompat(Path.root, true, true, 1).map((p) => p.toString()),\n    [\"a\"],\n  );\n\n  assert.deepStrictEqual(\n    pathSet.findCompat(Path.root, false, false, 2).map((p) => p.toString()),\n    [\"a\", \"a.*\", \"a.a\", \"*.a\", \"*.*\"],\n  );\n\n  assert.deepStrictEqual(\n    pathSet\n      .findCompat(Path.parse(\"a.*\"), false, false)\n      .map((p) => p.toString()),\n    [\"a.*\"],\n  );\n\n  assert.deepStrictEqual(\n    pathSet.findCompat(Path.parse(\"a.*\"), false, true).map((p) => p.toString()),\n    [\"a.*\", \"a.a\"],\n  );\n\n  assert.deepStrictEqual(\n    pathSet.findCompat(Path.parse(\"a.*\"), true, false).map((p) => p.toString()),\n    [\"a.*\", \"*.*\"],\n  );\n\n  assert.deepStrictEqual(\n    pathSet.findCompat(Path.parse(\"a.*\"), true, true).map((p) => p.toString()),\n    [\"a.*\", \"a.a\", \"*.a\", \"*.*\"],\n  );\n});\n"
  },
  {
    "path": "test/path.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport Path from \"../lib/common/path.ts\";\n\nvoid test(\"parse\", () => {\n  assert.throws(() => Path.parse(\".\"));\n  assert.throws(() => Path.parse(\"a \"));\n  assert.throws(() => Path.parse(\".a\"));\n  assert.throws(() => Path.parse(\"a.\"));\n  assert.throws(() => Path.parse(\"a..b\"));\n  assert.doesNotThrow(() => Path.parse(\"b*\"));\n  assert.doesNotThrow(() => Path.parse(\"*b\"));\n  assert.throws(() => Path.parse(\"a.b c.d\"));\n  assert.throws(() => Path.parse(\"a[\"));\n  assert.throws(() => Path.parse(\"a[b\"));\n  assert.throws(() => Path.parse(\"a[b:\"));\n  assert.throws(() => Path.parse('a[b:\"waef]'));\n  assert.doesNotThrow(() => Path.parse(\"*\"));\n  assert.throws(() => Path.parse(\"\"));\n});\n\nvoid test(\"toString\", () => {\n  const path1 = Path.parse('abc.[ abc=123 and def=\" abc \" ].123');\n  const path2 = Path.parse('abc.[abc = 123 AND def = \" abc \"].123');\n  assert.strictEqual(path1.toString(), path2.toString());\n});\n\nvoid test(\"slice\", () => {\n  const path = Path.parse(`a.*.b.[x = \"y\"].c`);\n  const sliced = path.slice(1, -1);\n  assert.strictEqual(sliced.toString(), '*.b.[x = \"y\"]');\n  assert.strictEqual(sliced.alias, 0b100);\n  assert.strictEqual(sliced.wildcard, 0b1);\n\n  const path2 = Path.parse(\"a.b:c.d\");\n\n  // Trim from right into colon region\n  assert.strictEqual(path2.slice(0, 3).toString(), \"a.b:c\");\n  assert.strictEqual(path2.slice(0, 3).colon, 1);\n\n  // Trim from right past colon region\n  assert.strictEqual(path2.slice(0, 2).toString(), \"a.b\");\n  assert.strictEqual(path2.slice(0, 2).colon, 0);\n\n  // Start exactly at colon boundary (all-colon result)\n  assert.strictEqual(path2.slice(2, 4).toString(), \":c.d\");\n  assert.strictEqual(path2.slice(2, 4).colon, 2);\n\n  // Start past colon boundary (colon dropped)\n  assert.strictEqual(path2.slice(3, 4).toString(), \"d\");\n  assert.strictEqual(path2.slice(3, 4).colon, 0);\n\n  // Span across boundary from both sides\n  assert.strictEqual(path2.slice(1, 3).toString(), \"b:c\");\n  assert.strictEqual(path2.slice(1, 3).colon, 1);\n});\n\nvoid test(\"concat\", () => {\n  // Alias and wildcard propagation\n  const c0 = Path.parse(\"a\").concat(Path.parse('*.[a = \"b\"]'));\n  assert.strictEqual(c0.toString(), 'a.*.[a = \"b\"]');\n  assert.strictEqual(c0.alias, 0b100);\n  assert.strictEqual(c0.wildcard, 0b10);\n\n  // Left plain, right all-colon\n  const allColon = Path.parse(\"a:b.c\").slice(1, 3);\n  const c1 = Path.parse(\"a.b\").concat(allColon);\n  assert.strictEqual(c1.toString(), \"a.b:b.c\");\n  assert.strictEqual(c1.colon, 2);\n\n  // Left colon, right all-colon\n  const c2 = Path.parse(\"a:b\").concat(Path.parse(\"a.b:c.d\").slice(2, 4));\n  assert.strictEqual(c2.toString(), \"a:b.c.d\");\n  assert.strictEqual(c2.colon, 3);\n\n  // Both have mixed colon — should throw\n  assert.throws(() => Path.parse(\"a:b\").concat(Path.parse(\"c:d\")));\n});\n\nvoid test(\"old alias format\", () => {\n  // Empty brackets\n  const empty = Path.parse(\"a.[].b\");\n  assert.strictEqual(empty.alias, 0b10);\n  assert.strictEqual(empty.toString(), \"a.[TRUE].b\");\n\n  // Single key-value\n  const single = Path.parse(\"a.[b:c].d\");\n  assert.strictEqual(single.alias, 0b10);\n  assert.strictEqual(single.toString(), 'a.[b = \"c\"].d');\n\n  // Multiple key-value pairs\n  const multi = Path.parse(\"a.[b:1,c:2].d\");\n  assert.strictEqual(multi.alias, 0b10);\n  assert.strictEqual(multi.toString(), 'a.[b = \"1\" AND c = \"2\"].d');\n\n  // Key with empty value\n  const emptyVal = Path.parse(\"a.[b:].d\");\n  assert.strictEqual(emptyVal.toString(), 'a.[b = \"\"].d');\n\n  // Value containing colons (split on first : only)\n  const colonVal = Path.parse(\"a.[b:c:d].e\");\n  assert.strictEqual(colonVal.toString(), 'a.[b = \"c:d\"].e');\n\n  // Value containing spaces (trimmed)\n  const spaceVal = Path.parse(\"a.[b:hello world].c\");\n  assert.strictEqual(spaceVal.toString(), 'a.[b = \"hello world\"].c');\n\n  // Unquoted values are trimmed\n  const trimmed = Path.parse(\"a.[b: hello ].c\");\n  assert.strictEqual(trimmed.toString(), 'a.[b = \"hello\"].c');\n\n  // Whitespace around keys is trimmed\n  const keySpace = Path.parse(\"a.[ b : c ].d\");\n  assert.strictEqual(keySpace.toString(), 'a.[b = \"c\"].d');\n\n  // Equivalence with new format\n  const oldFmt = Path.parse(\"x.[a:1,b:2].y\");\n  const newFmt = Path.parse('x.[a = \"1\" AND b = \"2\"].y');\n  assert.strictEqual(oldFmt.toString(), newFmt.toString());\n\n  // Nested old-format alias\n  const nested = Path.parse(\"a.[b.[x:1].c:2].d\");\n  assert.strictEqual(nested.toString(), 'a.[b.[x = \"1\"].c = \"2\"].d');\n\n  // New SQL format still works\n  const sql = Path.parse(\"a.[b = 1 AND c = 2].d\");\n  assert.strictEqual(sql.toString(), \"a.[b = 1 AND c = 2].d\");\n\n  // Double-quoted value containing closing bracket\n  const quotedBracket = Path.parse('a.[b:\"hello]world\"].c');\n  assert.strictEqual(quotedBracket.toString(), 'a.[b = \"hello]world\"].c');\n\n  // Double-quoted value containing comma\n  const quotedComma = Path.parse('a.[b:\"hello,world\"].c');\n  assert.strictEqual(quotedComma.toString(), 'a.[b = \"hello,world\"].c');\n\n  // Double-quoted value with escape sequences (JSON semantics)\n  const escaped = Path.parse('a.[b:\"hello\\\\\"world\"].c');\n  assert.strictEqual(escaped.toString(), 'a.[b = \"hello\\\\\"world\"].c');\n\n  // Single-quoted value\n  const singleQuoted = Path.parse(\"a.[b:'value'].c\");\n  assert.strictEqual(singleQuoted.toString(), 'a.[b = \"value\"].c');\n\n  // Invalid old format (no colon) still throws\n  assert.throws(() => Path.parse(\"[abc]\"));\n});\n\nvoid test(\"slice concat round-trip\", () => {\n  const path = Path.parse(\"a.b:c.d\");\n\n  // Round-trips at every split position up to and including the boundary\n  for (let i = 1; i <= path.paramLength; i++) {\n    const rejoined = path.slice(0, i).concat(path.slice(i));\n    assert.strictEqual(rejoined.toString(), path.toString());\n    assert.strictEqual(rejoined.colon, path.colon);\n  }\n\n  // Split inside the colon region: right half loses colon,\n  // but concat still extends the left's attr region\n  const left = path.slice(0, 3);\n  const right = path.slice(3);\n  assert.strictEqual(left.colon, 1);\n  assert.strictEqual(right.colon, 0);\n  const rejoined = left.concat(right);\n  assert.strictEqual(rejoined.toString(), \"a.b:c.d\");\n  assert.strictEqual(rejoined.colon, 2);\n});\n"
  },
  {
    "path": "test/ping.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport { parsePing } from \"../lib/ping.ts\";\n\nvoid test(\"linux Case-1\", () => {\n  const platform = \"linux\";\n  const stdout =\n    \"PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data\\n64 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=6.66 ms\\n64 bytes from 8.8.8.8: icmp_seq=2 ttl=118 time=6.50 ms\\n64 bytes from 8.8.8.8: icmp_seq=3 ttl=118 time=6.38 ms\\n\\n--- 8.8.8.8 ping statistics ---\\n3 packets transmitted, 3 received, 0% packet loss, time 402ms\\nrtt min/avg/max/mdev = 6.381/6.511/6.656/0.112 ms\";\n  const parsedResult = {\n    packetsTransmitted: 3,\n    packetsReceived: 3,\n    packetLoss: 0,\n    min: 6.381,\n    avg: 6.511,\n    max: 6.656,\n    mdev: 0.112,\n  };\n  const parsed = parsePing(platform, stdout);\n  assert.deepStrictEqual(parsedResult, parsed);\n});\n\nvoid test(\"linux Case-2\", () => {\n  const platform = \"linux\";\n  const stdout =\n    \"PING 10.251.9.108 (10.251.9.108): 56 data bytes\\n64 bytes from 10.251.9.108: icmp_seq=0 ttl=57 time=36.758 ms\\n64 bytes from 10.251.9.108: icmp_seq=1 ttl=57 time=69.094 ms\\n64 bytes from 10.251.9.108: icmp_seq=2 ttl=57 time=28.868 ms\\n--- 10.251.9.108 ping statistics ---3 packets transmitted, 3 packets received, 0% packet loss\\nround-trip min/avg/max/stddev = 28.868/44.907/69.094/17.404 ms\";\n  const parsedResult = {\n    packetsTransmitted: 3,\n    packetsReceived: 3,\n    packetLoss: 0,\n    min: 28.868,\n    avg: 44.907,\n    max: 69.094,\n    mdev: 17.404,\n  };\n  const parsed = parsePing(platform, stdout);\n  assert.deepStrictEqual(parsedResult, parsed);\n});\n"
  },
  {
    "path": "test/reactive-store.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport { ComputedSignal } from \"../ui/signals.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport {\n  covers,\n  subtract,\n  areEquivalent,\n} from \"../lib/common/expression/synth.ts\";\n\n// Import actual reactive-store exports (using mocked store.ts via esbuild plugin)\nimport {\n  fetch as reactiveStoreFetch,\n  count as reactiveStoreCount,\n  createBookmark as reactiveStoreCreateBookmark,\n  invalidate,\n  QuerySignal,\n  Bookmark,\n} from \"../ui/reactive-store.ts\";\n\n// Test-only exports added by build/test.ts plugin at build time\nimport * as reactiveStore from \"../ui/reactive-store.ts\";\nconst compareFunction = (reactiveStore as Record<string, unknown>)[\n  \"_testCompareFunction\"\n] as (sort: Record<string, number>) => (a: unknown, b: unknown) => number;\nconst getObjectId = (reactiveStore as Record<string, unknown>)[\n  \"_testGetObjectId\"\n] as (resourceType: string, obj: unknown) => string;\nconst applyDefaultSort = (reactiveStore as Record<string, unknown>)[\n  \"_testApplyDefaultSort\"\n] as (\n  resourceType: string,\n  sort?: Record<string, number>,\n) => Record<string, number>;\n\nconst stores = (reactiveStore as Record<string, unknown>)[\"_testStores\"] as Map<\n  string,\n  unknown\n>;\nconst getStore = (reactiveStore as Record<string, unknown>)[\n  \"_testGetStore\"\n] as (resource: string) => unknown;\n\ninterface FetchedRegion {\n  filter: Expression;\n  timestamp: number;\n  filterStr: string;\n}\ninterface ResourceCache {\n  objects: Map<string, unknown>;\n  counts: Map<string, unknown>;\n  bookmarks: Map<string, unknown>;\n  fetchedRegions: FetchedRegion[];\n}\n\nfunction getCacheState(resource: string): {\n  objectCount: number;\n  regionCount: number;\n  regions: Array<{ filter: Expression; timestamp: number }>;\n  fetchQueryCount: number;\n} {\n  const store = getStore(resource);\n  const cache = (store as { cache: ResourceCache }).cache;\n  const fetchQueries = (\n    store as {\n      fetchQueries: Map<\n        string,\n        { weakRef: globalThis.WeakRef<QuerySignal<unknown[]>> }\n      >;\n    }\n  ).fetchQueries;\n\n  let activeFetchQueries = 0;\n  for (const [, entry] of fetchQueries) {\n    if (entry.weakRef.deref()) activeFetchQueries++;\n  }\n\n  return {\n    objectCount: cache.objects.size,\n    regionCount: cache.fetchedRegions.length,\n    regions: cache.fetchedRegions.map((r) => ({\n      filter: r.filter,\n      timestamp: r.timestamp,\n    })),\n    fetchQueryCount: activeFetchQueries,\n  };\n}\n\nfunction clearStores(): void {\n  stores.clear();\n}\n\nfunction forcePruneCache(resource: string): void {\n  const store = getStore(resource);\n  (store as { pruneCache: () => void }).pruneCache();\n}\n\n// Import mock utilities for controlling xhrRequest behavior in tests\nimport {\n  mockRegisterHandler,\n  mockClearHandlers,\n  mockFetchHandler,\n  mockCountHandler,\n  mockGetRequestLog,\n  mockClearRequestLog,\n} from \"./mocks/store.ts\";\n\n// =============================================================================\n// compareFunction Tests\n// =============================================================================\n\nvoid test(\"compareFunction sorts by multiple fields with mixed asc/desc\", () => {\n  const sort = { category: 1, priority: -1, name: 1 };\n  const compare = compareFunction(sort);\n\n  const items = [\n    { category: \"b\", priority: 1, name: \"z\" },\n    { category: \"a\", priority: 2, name: \"y\" },\n    { category: \"a\", priority: 2, name: \"x\" },\n    { category: \"a\", priority: 1, name: \"w\" },\n  ];\n  items.sort(compare);\n\n  assert.deepStrictEqual(items, [\n    { category: \"a\", priority: 2, name: \"x\" },\n    { category: \"a\", priority: 2, name: \"y\" },\n    { category: \"a\", priority: 1, name: \"w\" },\n    { category: \"b\", priority: 1, name: \"z\" },\n  ]);\n});\n\nvoid test(\"compareFunction handles DeviceID.ID nested value objects\", () => {\n  const sort = { \"DeviceID.ID\": 1 };\n  const compare = compareFunction(sort);\n\n  const items = [\n    { \"DeviceID.ID\": { value: [\"device-c\"] } },\n    { \"DeviceID.ID\": { value: [\"device-a\"] } },\n    { \"DeviceID.ID\": {} }, // missing value array treated as null\n    { \"DeviceID.ID\": { value: [\"device-b\"] } },\n  ];\n  items.sort(compare);\n\n  // null/missing comes first due to type weight ordering\n  assert.strictEqual(\n    (items[0][\"DeviceID.ID\"] as { value?: string[] }).value,\n    undefined,\n  );\n  assert.strictEqual(\n    (items[1][\"DeviceID.ID\"] as { value: string[] }).value[0],\n    \"device-a\",\n  );\n});\n\nvoid test(\"compareFunction handles mixed types with correct ordering\", () => {\n  const sort = { value: 1 };\n  const compare = compareFunction(sort);\n\n  // Test that type weights work: null=1, number=2, string=3\n  const items = [{ value: \"z\" }, { value: 5 }, { value: null }];\n  items.sort(compare);\n\n  assert.strictEqual(items[0].value, null);\n  assert.strictEqual(items[1].value, 5);\n  assert.strictEqual(items[2].value, \"z\");\n});\n\n// =============================================================================\n// getObjectId Tests\n// =============================================================================\n\nvoid test(\"getObjectId extracts correct ID based on resource type\", () => {\n  const device = {\n    \"DeviceID.ID\": \"device-123\",\n    _id: \"should-not-use\",\n  };\n  const preset = { _id: \"preset-123\", name: \"My Preset\" };\n\n  assert.strictEqual(getObjectId(\"devices\", device), \"device-123\");\n  assert.strictEqual(getObjectId(\"presets\", preset), \"preset-123\");\n  assert.strictEqual(getObjectId(\"faults\", {}), \"\");\n});\n\n// =============================================================================\n// applyDefaultSort Tests\n// =============================================================================\n\nvoid test(\"applyDefaultSort adds correct default key without overriding\", () => {\n  assert.deepStrictEqual(applyDefaultSort(\"devices\"), { \"DeviceID.ID\": 1 });\n  assert.deepStrictEqual(applyDefaultSort(\"presets\"), { _id: 1 });\n  assert.deepStrictEqual(applyDefaultSort(\"devices\", { name: -1 }), {\n    name: -1,\n    \"DeviceID.ID\": 1,\n  });\n  assert.deepStrictEqual(applyDefaultSort(\"devices\", { \"DeviceID.ID\": -1 }), {\n    \"DeviceID.ID\": -1,\n  });\n\n  // Does not mutate original\n  const original = { name: 1 };\n  applyDefaultSort(\"devices\", original);\n  assert.deepStrictEqual(original, { name: 1 });\n});\n\n// =============================================================================\n// QuerySignal Tests\n// =============================================================================\n\nvoid test(\"QuerySignal state management and disposal\", () => {\n  const signal = new QuerySignal<number>(0);\n\n  let state = signal.get();\n  assert.strictEqual(state.value, 0);\n  assert.strictEqual(state.timestamp, 0);\n  assert.strictEqual(state.loading, true);\n\n  const now = Date.now();\n  signal._update(42, now, false);\n  state = signal.get();\n  assert.strictEqual(state.value, 42);\n  assert.strictEqual(state.loading, false);\n\n  const stateBefore = signal.get();\n  signal._update(42, now, false);\n  assert.strictEqual(signal.get(), stateBefore);\n\n  signal[Symbol.dispose]();\n  assert.throws(() => signal.get(), { message: \"Cannot read disposed signal\" });\n  signal[Symbol.dispose](); // Double disposal is safe\n});\n\nvoid test(\"QuerySignal registers dependency when read by ComputedSignal\", () => {\n  const querySignal = new QuerySignal<number>(0);\n\n  let computeCount = 0;\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    return querySignal.get().value * 2;\n  });\n\n  assert.strictEqual(computed.get(), 0);\n  assert.strictEqual(computeCount, 1);\n\n  querySignal._update(21, Date.now(), false);\n\n  assert.strictEqual(computed.get(), 42);\n  assert.strictEqual(computeCount, 2);\n});\n\n// =============================================================================\n// fetch() Tests\n// =============================================================================\n\nvoid test(\"fetch() returns QuerySignal and populates data\", async () => {\n  mockClearHandlers();\n\n  const testData = [\n    { _id: \"preset-1\", name: \"First Preset\" },\n    { _id: \"preset-2\", name: \"Second Preset\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/presets/\", testData));\n\n  const filter: Expression = new Expression.Literal(true);\n  const signal = reactiveStoreFetch(\"presets\", filter);\n\n  assert.ok(signal instanceof QuerySignal);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual(state.loading, false);\n  assert.strictEqual(state.value.length, 2);\n  assert.strictEqual((state.value[0] as { _id: string })._id, \"preset-1\");\n});\n\nvoid test(\"fetch() applies default sort based on resource type\", async () => {\n  mockClearHandlers();\n\n  const deviceData = [\n    { \"DeviceID.ID\": { value: [\"device-b\"] } },\n    { \"DeviceID.ID\": { value: [\"device-a\"] } },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/devices/\", deviceData));\n\n  const signal = reactiveStoreFetch(\"devices\", new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual(state.value.length, 2);\n  const firstDevice = state.value[0] as {\n    \"DeviceID.ID\": { value: string[] };\n  };\n  assert.strictEqual(firstDevice[\"DeviceID.ID\"].value[0], \"device-a\");\n});\n\nvoid test(\"fetch() returns same signal for same query\", () => {\n  mockClearHandlers();\n  const testData = [\n    { _id: \"fault-1\", type: \"test\" },\n    { _id: \"fault-2\", type: \"other\" },\n    { _id: \"fault-3\", type: \"test\" },\n  ];\n  mockRegisterHandler(mockFetchHandler(\"api/faults/\", testData));\n\n  const filter: Expression = Expression.parse('type = \"test\"');\n  const signal1 = reactiveStoreFetch(\"faults\", filter);\n  const signal2 = reactiveStoreFetch(\"faults\", filter);\n\n  assert.strictEqual(signal1, signal2);\n});\n\nvoid test(\"fetch() with custom sort option\", async () => {\n  mockClearHandlers();\n\n  const testData = [\n    { _id: \"3\", priority: 1 },\n    { _id: \"1\", priority: 3 },\n    { _id: \"2\", priority: 2 },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/faults/\", testData));\n\n  const signal = reactiveStoreFetch(\"faults\", new Expression.Literal(true), {\n    sort: { priority: -1 },\n  });\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual((state.value[0] as { priority: number }).priority, 3);\n  assert.strictEqual((state.value[1] as { priority: number }).priority, 2);\n  assert.strictEqual((state.value[2] as { priority: number }).priority, 1);\n});\n\nvoid test(\"fetch() filters data correctly\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const testData = [\n    { _id: \"task-1\", status: \"pending\", priority: 1 },\n    { _id: \"task-2\", status: \"completed\", priority: 2 },\n    { _id: \"task-3\", status: \"pending\", priority: 3 },\n    { _id: \"task-4\", status: \"completed\", priority: 1 },\n    { _id: \"task-5\", status: \"pending\", priority: 2 },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/faults/\", testData));\n\n  const filter: Expression = Expression.parse('status = \"pending\"');\n  const signal = reactiveStoreFetch(\"faults\", filter);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual(state.value.length, 3, \"Should return 3 pending tasks\");\n  assert.ok(\n    state.value.every(\n      (item) => (item as { status: string }).status === \"pending\",\n    ),\n    \"All returned items should have status 'pending'\",\n  );\n\n  const ids = state.value.map((item) => (item as { _id: string })._id).sort();\n  assert.deepStrictEqual(ids, [\"task-1\", \"task-3\", \"task-5\"]);\n});\n\n// =============================================================================\n// count() Tests\n// =============================================================================\n\nvoid test(\"count() returns QuerySignal with count value\", async () => {\n  mockClearHandlers();\n\n  const testData = Array.from({ length: 42 }, (_, i) => ({\n    _id: `preset-${i}`,\n    name: `Preset ${i}`,\n    active: i < 15,\n  }));\n  mockRegisterHandler(mockCountHandler(\"api/presets/\", testData));\n\n  const filter: Expression = Expression.parse(\"active = true\");\n  const signal = reactiveStoreCount(\"presets\", filter);\n\n  assert.ok(signal instanceof QuerySignal);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual(state.loading, false);\n  assert.strictEqual(state.value, 15);\n});\n\nvoid test(\"count() returns same signal for same query\", () => {\n  mockClearHandlers();\n  const testData = [\n    ...Array.from({ length: 10 }, (_, i) => ({ _id: `ui.config-${i}` })),\n    ...Array.from({ length: 5 }, (_, i) => ({ _id: `api.config-${i}` })),\n  ];\n  mockRegisterHandler(mockCountHandler(\"api/config/\", testData));\n\n  const filter: Expression = Expression.parse('_id LIKE \"ui.%\"');\n  const signal1 = reactiveStoreCount(\"config\", filter);\n  const signal2 = reactiveStoreCount(\"config\", filter);\n\n  assert.strictEqual(signal1, signal2);\n});\n\n// =============================================================================\n// createBookmark() Tests\n// =============================================================================\n\nvoid test(\"createBookmark() returns QuerySignal with Bookmark\", async () => {\n  mockClearHandlers();\n\n  const rowAtOffset = [{ _id: \"preset-5\", name: \"Fifth\" }];\n\n  mockRegisterHandler(mockFetchHandler(\"api/presets/\", rowAtOffset));\n\n  const filter: Expression = new Expression.Literal(true);\n  const sort = { _id: 1 };\n  const signal = reactiveStoreCreateBookmark(\"presets\", filter, sort, 5);\n\n  assert.ok(signal instanceof QuerySignal);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual(state.loading, false);\n  assert.ok(state.value instanceof Bookmark);\n});\n\nvoid test(\"createBookmark() returns null when offset is beyond result count\", async () => {\n  mockClearHandlers();\n\n  mockRegisterHandler(mockFetchHandler(\"api/presets/\", []));\n\n  const filter: Expression = new Expression.Literal(true);\n  const sort = { _id: 1 };\n  const signal = reactiveStoreCreateBookmark(\"presets\", filter, sort, 1000);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual(state.loading, false);\n  assert.strictEqual(state.value, null);\n});\n\nvoid test(\"Bookmark.applySkip() and applyLimit() modify filter correctly\", async () => {\n  mockClearHandlers();\n\n  const rowAtOffset = [{ _id: \"preset-10\", active: true }];\n  mockRegisterHandler(mockFetchHandler(\"api/presets/\", rowAtOffset));\n\n  const filter: Expression = Expression.parse(\"active = true\");\n  const sort = { _id: 1 };\n  const signal = reactiveStoreCreateBookmark(\"presets\", filter, sort, 10);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.ok(state.value instanceof Bookmark);\n\n  const skipFilter = state.value!.applySkip(filter);\n  assert.ok(\n    skipFilter instanceof Expression,\n    \"skipFilter should be an Expression\",\n  );\n  // applySkip returns AND(filter, bookmarkCondition)\n  assert.ok(\n    skipFilter instanceof Expression.Binary && skipFilter.operator === \"AND\",\n    \"skipFilter should be an AND expression\",\n  );\n\n  const limitFilter = state.value!.applyLimit(filter);\n  assert.ok(\n    limitFilter instanceof Expression,\n    \"limitFilter should be an Expression\",\n  );\n  // applyLimit returns AND(filter, NOT(bookmarkCondition))\n  assert.ok(\n    limitFilter instanceof Expression.Binary && limitFilter.operator === \"AND\",\n    \"limitFilter should be an AND expression\",\n  );\n});\n\n// =============================================================================\n// Caching Tests - fetch() results caching\n// =============================================================================\n\nvoid test(\"fetch() uses cached data without making new request\", async () => {\n  mockClearHandlers();\n\n  const testData = [\n    { _id: \"item-1\", name: \"First\" },\n    { _id: \"item-2\", name: \"Second\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/provisions/\", testData));\n\n  const filter: Expression = new Expression.Literal(true);\n  const signal1 = reactiveStoreFetch(\"provisions\", filter);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state1 = signal1.get();\n  assert.strictEqual(state1.loading, false);\n  assert.strictEqual(state1.value.length, 2);\n\n  mockClearRequestLog();\n\n  const signal2 = reactiveStoreFetch(\"provisions\", filter);\n  assert.strictEqual(signal1, signal2);\n\n  const log = mockGetRequestLog();\n  assert.strictEqual(\n    log.length,\n    0,\n    \"Should not make new request for cached query\",\n  );\n});\n\nvoid test(\"fetch() with freshness=0 uses cached objects without refetch\", async () => {\n  mockClearHandlers();\n\n  const testData = [\n    { _id: \"cached-1\", value: \"original\" },\n    { _id: \"cached-2\", value: \"original\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/config/\", testData));\n\n  const signal1 = reactiveStoreFetch(\"config\", new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state1 = signal1.get();\n  assert.strictEqual(state1.value.length, 2);\n\n  mockClearRequestLog();\n\n  const filter2: Expression = Expression.parse('_id = \"cached-1\"');\n  const signal2 = reactiveStoreFetch(\"config\", filter2, { freshness: 0 });\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state2 = signal2.get();\n  assert.strictEqual(state2.value.length, 1);\n  assert.strictEqual((state2.value[0] as { _id: string })._id, \"cached-1\");\n});\n\n// =============================================================================\n// Stale Data Tests - show old data while fetching new data\n// =============================================================================\n\nvoid test(\"fetch() shows stale data with loading=true while fetching fresh data\", async () => {\n  mockClearHandlers();\n\n  const staleData = [{ _id: \"stale-item\", name: \"Stale Data\" }];\n  const freshData = [\n    { _id: \"stale-item\", name: \"Fresh Data\" },\n    { _id: \"new-item\", name: \"New Item\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/users/\", staleData));\n\n  const filter: Expression = new Expression.Literal(true);\n  const signal = reactiveStoreFetch(\"users\", filter);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const staleState = signal.get();\n  assert.strictEqual(staleState.loading, false);\n  assert.strictEqual(staleState.value.length, 1);\n  const staleTimestamp = staleState.timestamp;\n\n  mockClearHandlers();\n  mockRegisterHandler(mockFetchHandler(\"api/users/\", freshData, 100));\n\n  const futureTimestamp = Date.now() + 100000;\n  const signal2 = reactiveStoreFetch(\"users\", filter, {\n    freshness: futureTimestamp,\n  });\n\n  assert.strictEqual(signal, signal2);\n\n  const loadingState = signal.get();\n  assert.strictEqual(\n    loadingState.loading,\n    true,\n    \"Should be loading while fetching fresh data\",\n  );\n  assert.strictEqual(\n    loadingState.value.length,\n    1,\n    \"Should show stale data while loading\",\n  );\n  assert.strictEqual(\n    loadingState.timestamp,\n    staleTimestamp,\n    \"Timestamp should be from stale data\",\n  );\n\n  await new Promise((resolve) => setTimeout(resolve, 150));\n\n  const freshState = signal.get();\n  assert.strictEqual(\n    freshState.loading,\n    false,\n    \"Should not be loading after fetch completes\",\n  );\n  assert.strictEqual(freshState.value.length, 2, \"Should have fresh data\");\n  assert.ok(\n    freshState.timestamp > staleTimestamp,\n    \"Timestamp should be updated\",\n  );\n});\n\nvoid test(\"count() shows stale count with loading=true while fetching fresh count\", async () => {\n  mockClearHandlers();\n\n  const staleData = Array.from({ length: 10 }, (_, i) => ({\n    _id: `perm-${i}`,\n  }));\n  mockRegisterHandler(mockCountHandler(\"api/permissions/\", staleData));\n\n  const filter: Expression = new Expression.Literal(true);\n  const signal = reactiveStoreCount(\"permissions\", filter);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const staleState = signal.get();\n  assert.strictEqual(staleState.loading, false);\n  assert.strictEqual(staleState.value, 10);\n  const staleTimestamp = staleState.timestamp;\n\n  mockClearHandlers();\n  const freshData = Array.from({ length: 25 }, (_, i) => ({\n    _id: `perm-${i}`,\n  }));\n  mockRegisterHandler(mockCountHandler(\"api/permissions/\", freshData, 100));\n\n  const futureTimestamp = Date.now() + 100000;\n  const signal2 = reactiveStoreCount(\"permissions\", filter, {\n    freshness: futureTimestamp,\n  });\n\n  assert.strictEqual(signal, signal2);\n\n  const loadingState = signal.get();\n  assert.strictEqual(\n    loadingState.loading,\n    true,\n    \"Should be loading while fetching fresh count\",\n  );\n  assert.strictEqual(\n    loadingState.value,\n    10,\n    \"Should show stale count while loading\",\n  );\n\n  await new Promise((resolve) => setTimeout(resolve, 150));\n\n  const freshState = signal.get();\n  assert.strictEqual(\n    freshState.loading,\n    false,\n    \"Should not be loading after fetch completes\",\n  );\n  assert.strictEqual(freshState.value, 25, \"Should have fresh count\");\n  assert.ok(\n    freshState.timestamp > staleTimestamp,\n    \"Timestamp should be updated\",\n  );\n});\n\n// =============================================================================\n// Partial Data / Overlapping Query Tests\n// =============================================================================\n\nvoid test(\"fetch() fetches only missing data for partially covered query\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const allData = [\n    { _id: \"file-1\", region: \"A\" },\n    { _id: \"file-2\", region: \"A\" },\n    { _id: \"file-3\", region: \"B\" },\n    { _id: \"file-4\", region: \"B\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/files/\", allData));\n\n  const filterA: Expression = Expression.parse('region = \"A\"');\n  const signalA = reactiveStoreFetch(\"files\", filterA);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const stateA = signalA.get();\n  assert.strictEqual(stateA.value.length, 2);\n  assert.ok(\n    stateA.value.every((item) => (item as { region: string }).region === \"A\"),\n  );\n\n  mockClearRequestLog();\n\n  const filterB: Expression = Expression.parse('region = \"B\"');\n  const signalB = reactiveStoreFetch(\"files\", filterB);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const stateB = signalB.get();\n  assert.strictEqual(stateB.value.length, 2);\n  assert.ok(\n    stateB.value.every((item) => (item as { region: string }).region === \"B\"),\n  );\n\n  const log = mockGetRequestLog();\n  assert.ok(log.length > 0, \"Should make request for uncached region\");\n});\n\nvoid test(\"overlapping queries use and share cached objects\", async () => {\n  mockClearHandlers();\n\n  const allItems = [\n    { _id: \"shared-1\", type: \"X\", priority: 1 },\n    { _id: \"shared-2\", type: \"X\", priority: 2 },\n    { _id: \"shared-3\", type: \"Y\", priority: 1 },\n    { _id: \"shared-4\", type: \"Y\", priority: 2 },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(\"api/permissions/\", allItems));\n\n  const signalAll = reactiveStoreFetch(\n    \"permissions\",\n    new Expression.Literal(true),\n  );\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const stateAll = signalAll.get();\n  assert.strictEqual(stateAll.value.length, 4);\n\n  mockClearRequestLog();\n\n  // Subset queries should use cached objects\n  const filterX: Expression = Expression.parse('type = \"X\"');\n  const signalX = reactiveStoreFetch(\"permissions\", filterX, { freshness: 0 });\n\n  const filterP1: Expression = Expression.parse(\"priority = 1\");\n  const signalP1 = reactiveStoreFetch(\"permissions\", filterP1, {\n    freshness: 0,\n  });\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const stateX = signalX.get();\n  const stateP1 = signalP1.get();\n\n  // Verify filtering works correctly\n  assert.strictEqual(stateX.value.length, 2);\n  assert.ok(\n    stateX.value.every((item) => (item as { type: string }).type === \"X\"),\n    \"All items should have type X\",\n  );\n  assert.strictEqual(stateP1.value.length, 2);\n  assert.ok(\n    stateP1.value.every(\n      (item) => (item as { priority: number }).priority === 1,\n    ),\n    \"All items should have priority 1\",\n  );\n\n  // Verify same object reference is shared across queries\n  const itemFromX = stateX.value.find(\n    (i) => (i as { _id: string })._id === \"shared-1\",\n  );\n  const itemFromP1 = stateP1.value.find(\n    (i) => (i as { _id: string })._id === \"shared-1\",\n  );\n  assert.strictEqual(\n    itemFromX,\n    itemFromP1,\n    \"Same cached object should be shared across queries\",\n  );\n});\n\n// =============================================================================\n// Non-Overlapping FetchedRegions Tests\n// =============================================================================\n\nvoid test(\"fetching subset with newer timestamp replaces overlapping region\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"views\";\n\n  const allData = [\n    { _id: \"item-1\", category: \"X\" },\n    { _id: \"item-2\", category: \"X\" },\n    { _id: \"item-3\", category: \"Y\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, allData));\n\n  const signalAll = reactiveStoreFetch(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  let state = getCacheState(resource);\n  assert.strictEqual(\n    state.regionCount,\n    1,\n    \"Should have 1 region after first fetch\",\n  );\n  assert.strictEqual(state.objectCount, 3, \"Should have 3 objects cached\");\n\n  mockClearHandlers();\n  const subsetData = [\n    { _id: \"item-1\", category: \"X\" },\n    { _id: \"item-2\", category: \"X\" },\n  ];\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, subsetData));\n\n  const futureTimestamp = Date.now() + 100000;\n  const filterX: Expression = Expression.parse('category = \"X\"');\n  const signalX = reactiveStoreFetch(resource, filterX, {\n    freshness: futureTimestamp,\n  });\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  state = getCacheState(resource);\n  assert.strictEqual(\n    state.regionCount,\n    2,\n    \"Should have 2 non-overlapping regions\",\n  );\n\n  const [region1, region2] = state.regions.map((r) => r.filter);\n\n  const oneCoversX = covers(region1, filterX) || covers(region2, filterX);\n  assert.ok(oneCoversX, \"One region should cover filterX (category = X)\");\n\n  const unionOfRegions = Expression.or(region1, region2);\n  assert.ok(\n    covers(unionOfRegions, new Expression.Literal(true)),\n    \"Union of regions should cover the original filter (true)\",\n  );\n\n  assert.ok(\n    covers(new Expression.Literal(false), Expression.and(region1, region2)),\n    \"Regions should not overlap (areDisjoint should return true)\",\n  );\n\n  const diff = subtract(region1, region2);\n  assert.ok(\n    areEquivalent(diff, region2),\n    \"Regions should not overlap (diff should equal region2)\",\n  );\n\n  void signalAll;\n  void signalX;\n});\n\nvoid test(\"fetching same region with newer timestamp replaces old region entirely\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"config\";\n\n  const data = [{ _id: \"cfg-1\", value: \"test\" }];\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, data));\n\n  const filterA: Expression = Expression.parse('_id = \"cfg-1\"');\n  const signal1 = reactiveStoreFetch(resource, filterA);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  let state = getCacheState(resource);\n  const firstTimestamp = state.regions[0]?.timestamp;\n  assert.strictEqual(state.regionCount, 1, \"Should have 1 region\");\n\n  await new Promise((resolve) => setTimeout(resolve, 10));\n\n  mockClearHandlers();\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, data));\n\n  const futureTimestamp = Date.now() + 100000;\n  const signal2 = reactiveStoreFetch(resource, filterA, {\n    freshness: futureTimestamp,\n  });\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  state = getCacheState(resource);\n  assert.strictEqual(\n    state.regionCount,\n    1,\n    \"Should still have 1 region after refresh\",\n  );\n\n  assert.ok(\n    state.regions[0].timestamp > firstTimestamp,\n    \"Region timestamp should be updated\",\n  );\n\n  void signal1;\n  void signal2;\n});\n\nvoid test(\"multiple overlapping fetches result in non-overlapping regions\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"faults\";\n\n  const allData = [\n    { _id: \"fault-1\", type: \"A\" },\n    { _id: \"fault-2\", type: \"B\" },\n    { _id: \"fault-3\", type: \"C\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, allData));\n\n  const filterA: Expression = Expression.parse('type = \"A\"');\n  const filterB: Expression = Expression.parse('type = \"B\"');\n  const filterC: Expression = Expression.parse('type = \"C\"');\n\n  const signalA = reactiveStoreFetch(resource, filterA);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const signalB = reactiveStoreFetch(resource, filterB);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const signalC = reactiveStoreFetch(resource, filterC);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = getCacheState(resource);\n\n  assert.strictEqual(\n    state.regionCount,\n    3,\n    \"Should have 3 non-overlapping regions\",\n  );\n  assert.strictEqual(state.objectCount, 3, \"Should have 3 objects cached\");\n\n  assert.strictEqual(signalA.get().value.length, 1);\n  assert.strictEqual((signalA.get().value[0] as { type: string }).type, \"A\");\n  assert.strictEqual(signalB.get().value.length, 1);\n  assert.strictEqual((signalB.get().value[0] as { type: string }).type, \"B\");\n  assert.strictEqual(signalC.get().value.length, 1);\n  assert.strictEqual((signalC.get().value[0] as { type: string }).type, \"C\");\n\n  const regionFilters = state.regions.map((r) => r.filter);\n  assert.ok(\n    regionFilters.some((rf) => covers(rf, filterA)),\n    \"One region should cover filterA\",\n  );\n  assert.ok(\n    regionFilters.some((rf) => covers(rf, filterB)),\n    \"One region should cover filterB\",\n  );\n  assert.ok(\n    regionFilters.some((rf) => covers(rf, filterC)),\n    \"One region should cover filterC\",\n  );\n\n  for (let i = 0; i < regionFilters.length; i++) {\n    for (let j = i + 1; j < regionFilters.length; j++) {\n      assert.ok(\n        covers(\n          new Expression.Literal(false),\n          Expression.and(regionFilters[i], regionFilters[j]),\n        ),\n        `Regions ${i} and ${j} should not overlap (areDisjoint)`,\n      );\n    }\n  }\n});\n\n// =============================================================================\n// Cache Pruning Tests\n// =============================================================================\n\nvoid test(\"cache is cleared when all fetch signals are disposed\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"users\";\n  const data = [\n    { _id: \"user-1\", name: \"Alice\" },\n    { _id: \"user-2\", name: \"Bob\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, data));\n\n  const signal = reactiveStoreFetch(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = getCacheState(resource);\n  assert.strictEqual(state.objectCount, 2, \"Should have 2 objects cached\");\n  assert.strictEqual(state.regionCount, 1, \"Should have 1 region\");\n  assert.strictEqual(state.fetchQueryCount, 1, \"Should have 1 active query\");\n\n  signal[Symbol.dispose]();\n  void signal;\n\n  forcePruneCache(resource);\n\n  void getCacheState(resource);\n});\n\nvoid test(\"regions not serving active queries are pruned\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"provisions\";\n\n  const allData = [\n    { _id: \"prov-1\", category: \"X\" },\n    { _id: \"prov-2\", category: \"X\" },\n    { _id: \"prov-3\", category: \"Y\" },\n    { _id: \"prov-4\", category: \"Y\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, allData));\n\n  const filterX: Expression = Expression.parse('category = \"X\"');\n  const filterY: Expression = Expression.parse('category = \"Y\"');\n\n  const signalX = reactiveStoreFetch(resource, filterX);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  assert.strictEqual(signalX.get().value.length, 2);\n  assert.ok(\n    signalX\n      .get()\n      .value.every((item) => (item as { category: string }).category === \"X\"),\n  );\n\n  const signalY = reactiveStoreFetch(resource, filterY);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  assert.strictEqual(signalY.get().value.length, 2);\n  assert.ok(\n    signalY\n      .get()\n      .value.every((item) => (item as { category: string }).category === \"Y\"),\n  );\n\n  let state = getCacheState(resource);\n  assert.strictEqual(state.regionCount, 2, \"Should have 2 regions\");\n  assert.strictEqual(state.objectCount, 4, \"Should have 4 objects\");\n  assert.strictEqual(state.fetchQueryCount, 2, \"Should have 2 active queries\");\n\n  signalY[Symbol.dispose]();\n  forcePruneCache(resource);\n\n  state = getCacheState(resource);\n  assert.strictEqual(\n    state.regionCount,\n    1,\n    \"Should have 1 region after pruning (only X)\",\n  );\n  assert.strictEqual(\n    state.objectCount,\n    2,\n    \"Should have 2 objects after pruning (only X)\",\n  );\n\n  const remainingRegion = state.regions[0].filter;\n  assert.ok(\n    covers(remainingRegion, filterX),\n    \"Remaining region should cover filterX\",\n  );\n\n  void signalX;\n});\n\nvoid test(\"overlapping regions are consolidated after pruning\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"virtualParameters\";\n\n  const allData = [\n    { _id: \"vp-1\", type: \"A\" },\n    { _id: \"vp-2\", type: \"B\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, allData));\n\n  const signalAll = reactiveStoreFetch(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  let state = getCacheState(resource);\n  assert.strictEqual(state.regionCount, 1, \"Should have 1 region\");\n  assert.strictEqual(signalAll.get().value.length, 2);\n\n  const futureTimestamp = Date.now() + 100000;\n  const filterA: Expression = Expression.parse('type = \"A\"');\n\n  mockClearHandlers();\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, allData));\n\n  const signalA = reactiveStoreFetch(resource, filterA, {\n    freshness: futureTimestamp,\n  });\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  state = getCacheState(resource);\n  assert.strictEqual(\n    state.regionCount,\n    2,\n    \"Should have 2 regions after subset fetch\",\n  );\n\n  signalAll[Symbol.dispose]();\n  forcePruneCache(resource);\n\n  state = getCacheState(resource);\n\n  assert.strictEqual(\n    state.regionCount,\n    1,\n    \"Should have 1 region after pruning\",\n  );\n  assert.strictEqual(\n    state.objectCount,\n    1,\n    \"Should have 1 object (only type A)\",\n  );\n\n  const remainingRegion = state.regions[0].filter;\n  assert.ok(\n    covers(remainingRegion, filterA),\n    \"Remaining region should cover filterA\",\n  );\n\n  void signalA;\n});\n\n// =============================================================================\n// invalidate() Tests\n// =============================================================================\n\nvoid test(\"invalidate() triggers re-fetch for stale fetch queries\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"presets\";\n  const staleData = [{ _id: \"p-1\", name: \"Old\" }];\n  const freshData = [\n    { _id: \"p-1\", name: \"Updated\" },\n    { _id: \"p-2\", name: \"New\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, staleData));\n\n  const signal = reactiveStoreFetch(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const staleState = signal.get();\n  assert.strictEqual(staleState.loading, false);\n  assert.strictEqual(staleState.value.length, 1);\n  assert.strictEqual((staleState.value[0] as { name: string }).name, \"Old\");\n\n  // Replace handler with fresh data\n  mockClearHandlers();\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, freshData));\n\n  // Invalidate with a future timestamp so all current data is stale\n  invalidate(Date.now() + 100000);\n\n  // Wait for re-fetch to complete\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const freshState = signal.get();\n  assert.strictEqual(freshState.loading, false, \"Should finish loading\");\n  assert.strictEqual(freshState.value.length, 2, \"Should have fresh data\");\n  const freshNames = freshState.value\n    .map((item) => (item as { name: string }).name)\n    .sort();\n  assert.deepStrictEqual(\n    freshNames,\n    [\"New\", \"Updated\"],\n    \"Should have updated data\",\n  );\n});\n\nvoid test(\"invalidate() triggers re-fetch for stale count queries\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"faults\";\n  const staleData = Array.from({ length: 5 }, (_, i) => ({ _id: `f-${i}` }));\n  const freshData = Array.from({ length: 12 }, (_, i) => ({ _id: `f-${i}` }));\n\n  mockRegisterHandler(mockCountHandler(`api/${resource}/`, staleData));\n\n  const signal = reactiveStoreCount(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const staleState = signal.get();\n  assert.strictEqual(staleState.loading, false);\n  assert.strictEqual(staleState.value, 5);\n\n  // Replace handler with fresh data\n  mockClearHandlers();\n  mockRegisterHandler(mockCountHandler(`api/${resource}/`, freshData));\n\n  invalidate(Date.now() + 100000);\n\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const freshState = signal.get();\n  assert.strictEqual(freshState.loading, false, \"Should finish loading\");\n  assert.strictEqual(freshState.value, 12, \"Should have fresh count\");\n});\n\nvoid test(\"invalidate() preserves stale data while loading\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"provisions\";\n  const staleData = [{ _id: \"item-1\", value: \"stale\" }];\n  const freshData = [{ _id: \"item-1\", value: \"fresh\" }];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, staleData));\n\n  const signal = reactiveStoreFetch(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const staleState = signal.get();\n  assert.strictEqual(staleState.loading, false);\n  assert.strictEqual(staleState.value.length, 1);\n\n  // Use a delayed handler so we can observe the loading state\n  mockClearHandlers();\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, freshData, 100));\n\n  invalidate(Date.now() + 100000);\n\n  // Immediately after invalidate, should be loading with stale data\n  const loadingState = signal._peek();\n  assert.strictEqual(\n    loadingState.loading,\n    true,\n    \"Should be loading after invalidate\",\n  );\n  assert.strictEqual(\n    loadingState.value.length,\n    1,\n    \"Should still have stale data while loading\",\n  );\n  assert.strictEqual(\n    (loadingState.value[0] as { value: string }).value,\n    \"stale\",\n    \"Stale data should be preserved while loading\",\n  );\n\n  // Wait for re-fetch to complete\n  await new Promise((resolve) => setTimeout(resolve, 150));\n\n  const freshState = signal.get();\n  assert.strictEqual(freshState.loading, false, \"Should finish loading\");\n  assert.strictEqual(\n    (freshState.value[0] as { value: string }).value,\n    \"fresh\",\n    \"Should have fresh data after loading completes\",\n  );\n});\n\nvoid test(\"invalidate() skips queries that are already loading\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"config\";\n  const data = [{ _id: \"cfg-1\", value: \"test\" }];\n\n  // Use a slow handler so initial fetch is still in-flight\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, data, 200));\n\n  const signal = reactiveStoreFetch(resource, new Expression.Literal(true));\n\n  // Signal should still be loading from initial fetch\n  const state = signal._peek();\n  assert.strictEqual(\n    state.loading,\n    true,\n    \"Should be loading from initial fetch\",\n  );\n\n  mockClearRequestLog();\n\n  // Invalidate while still loading — should be a no-op\n  invalidate(Date.now() + 100000);\n\n  const log = mockGetRequestLog();\n  assert.strictEqual(\n    log.length,\n    0,\n    \"Should not trigger additional request while already loading\",\n  );\n\n  // Wait for original fetch to complete\n  await new Promise((resolve) => setTimeout(resolve, 250));\n\n  const finalState = signal.get();\n  assert.strictEqual(finalState.loading, false);\n  assert.strictEqual(finalState.value.length, 1);\n});\n\nvoid test(\"invalidate() skips queries newer than the given timestamp\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"files\";\n  const data = [{ _id: \"file-1\", name: \"Test\" }];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, data));\n\n  const signal = reactiveStoreFetch(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const state = signal.get();\n  assert.strictEqual(state.loading, false);\n  assert.strictEqual(state.value.length, 1);\n\n  mockClearRequestLog();\n\n  // Invalidate with a timestamp in the past (older than fetched data)\n  invalidate(1);\n\n  const log = mockGetRequestLog();\n  assert.strictEqual(\n    log.length,\n    0,\n    \"Should not trigger request when data is newer than invalidation timestamp\",\n  );\n\n  // Signal state should be unchanged\n  const unchanged = signal.get();\n  assert.strictEqual(unchanged.loading, false, \"Should not be loading\");\n  assert.strictEqual(unchanged.value.length, 1, \"Data should be unchanged\");\n});\n\nvoid test(\"invalidate() refreshes queries across multiple resource stores\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const presetsData = [{ _id: \"preset-1\", name: \"P1\" }];\n  const faultsData = [{ _id: \"fault-1\", type: \"error\" }];\n\n  mockRegisterHandler(mockFetchHandler(\"api/presets/\", presetsData));\n  mockRegisterHandler(mockFetchHandler(\"api/faults/\", faultsData));\n  mockRegisterHandler(mockCountHandler(\"api/faults/\", faultsData));\n\n  const presetSignal = reactiveStoreFetch(\n    \"presets\",\n    new Expression.Literal(true),\n  );\n  const faultSignal = reactiveStoreFetch(\n    \"faults\",\n    new Expression.Literal(true),\n  );\n  const faultCount = reactiveStoreCount(\"faults\", new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  assert.strictEqual(presetSignal.get().value.length, 1);\n  assert.strictEqual(faultSignal.get().value.length, 1);\n  assert.strictEqual(faultCount.get().value, 1);\n\n  // Replace with fresh data\n  mockClearHandlers();\n  const freshPresets = [\n    { _id: \"preset-1\", name: \"P1\" },\n    { _id: \"preset-2\", name: \"P2\" },\n  ];\n  const freshFaults = [\n    { _id: \"fault-1\", type: \"error\" },\n    { _id: \"fault-2\", type: \"warning\" },\n    { _id: \"fault-3\", type: \"error\" },\n  ];\n  mockRegisterHandler(mockFetchHandler(\"api/presets/\", freshPresets));\n  mockRegisterHandler(mockFetchHandler(\"api/faults/\", freshFaults));\n  mockRegisterHandler(mockCountHandler(\"api/faults/\", freshFaults));\n\n  invalidate(Date.now() + 100000);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  assert.strictEqual(\n    presetSignal.get().value.length,\n    2,\n    \"Presets should be refreshed\",\n  );\n  assert.strictEqual(\n    faultSignal.get().value.length,\n    3,\n    \"Faults fetch should be refreshed\",\n  );\n  assert.strictEqual(\n    faultCount.get().value,\n    3,\n    \"Faults count should be refreshed\",\n  );\n});\n\nvoid test(\"fetch() removes deleted records from cache on refresh\", async () => {\n  mockClearHandlers();\n  clearStores();\n\n  const resource = \"presets\";\n  const initialData = [\n    { _id: \"p-1\", name: \"First\" },\n    { _id: \"p-2\", name: \"Second\" },\n    { _id: \"p-3\", name: \"Third\" },\n  ];\n\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, initialData));\n\n  const signal = reactiveStoreFetch(resource, new Expression.Literal(true));\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const initialState = signal.get();\n  assert.strictEqual(initialState.value.length, 3);\n\n  // Simulate server-side deletion: p-2 is deleted\n  const afterDeleteData = [\n    { _id: \"p-1\", name: \"First\" },\n    { _id: \"p-3\", name: \"Third\" },\n  ];\n\n  mockClearHandlers();\n  mockRegisterHandler(mockFetchHandler(`api/${resource}/`, afterDeleteData));\n\n  invalidate(Date.now() + 100000);\n  await new Promise((resolve) => setTimeout(resolve, 50));\n\n  const afterState = signal.get();\n  assert.strictEqual(\n    afterState.value.length,\n    2,\n    \"Deleted record should be removed\",\n  );\n  const ids = afterState.value\n    .map((item) => (item as { _id: string })._id)\n    .sort();\n  assert.deepStrictEqual(\n    ids,\n    [\"p-1\", \"p-3\"],\n    \"Only non-deleted records remain\",\n  );\n});\n"
  },
  {
    "path": "test/signals.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport {\n  ConstSignal,\n  SignalBase,\n  StateSignal,\n  ComputedSignal,\n  Watcher,\n  setTimeout,\n  setInterval,\n} from \"../ui/signals.ts\";\n\n// =============================================================================\n// ConstSignal Tests\n// =============================================================================\n\nvoid test(\"ConstSignal returns constant value\", () => {\n  const signal = new ConstSignal(42);\n  assert.strictEqual(signal.get(), 42);\n  assert.strictEqual(signal.get(), 42);\n\n  // Works with different types\n  const strSignal = new ConstSignal(\"hello\");\n  assert.strictEqual(strSignal.get(), \"hello\");\n\n  const objSignal = new ConstSignal({ a: 1 });\n  assert.strictEqual(objSignal.get().a, 1);\n  assert.strictEqual(objSignal.get(), objSignal.get()); // Same reference\n});\n\nvoid test(\"ConstSignal extends SignalBase but doesn't allocate _sinks\", () => {\n  const constant = new ConstSignal(42);\n\n  // ConstSignal extends SignalBase for proper type hierarchy\n  assert.strictEqual(constant instanceof SignalBase, true);\n\n  // But doesn't allocate _sinks (optimization)\n  assert.strictEqual((constant as any)._sinks, undefined);\n});\n\n// =============================================================================\n// StateSignal Tests\n// =============================================================================\n\nvoid test(\"StateSignal get and set\", () => {\n  const signal = new StateSignal(42);\n  assert.strictEqual(signal.get(), 42);\n\n  signal.set(100);\n  assert.strictEqual(signal.get(), 100);\n});\n\nvoid test(\"StateSignal.set() with same value (Object.is) doesn't trigger updates\", () => {\n  const signal = new StateSignal(1);\n  let computeCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    return signal.get() * 2;\n  });\n\n  assert.strictEqual(computed.get(), 2);\n  assert.strictEqual(computeCount, 1);\n\n  // Set to same value\n  signal.set(1);\n  assert.strictEqual(computed.get(), 2);\n  assert.strictEqual(computeCount, 1);\n\n  // Object.is(NaN, NaN) is true\n  const nanSignal = new StateSignal(NaN);\n  let nanComputeCount = 0;\n  const nanComputed = new ComputedSignal(() => {\n    nanComputeCount++;\n    return nanSignal.get();\n  });\n  nanComputed.get();\n  nanSignal.set(NaN);\n  nanComputed.get();\n  assert.strictEqual(nanComputeCount, 1);\n});\n\nvoid test(\"Can subclass StateSignal\", () => {\n  class Counter extends StateSignal<number> {\n    increment(): void {\n      this.set(this.get() + 1);\n    }\n  }\n\n  const counter = new Counter(0);\n  counter.increment();\n  counter.increment();\n\n  assert.strictEqual(counter.get(), 2);\n});\n\n// =============================================================================\n// ComputedSignal Tests\n// =============================================================================\n\nvoid test(\"ComputedSignal is lazy and memoized\", () => {\n  let computeCount = 0;\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    return 1 + 2;\n  });\n\n  // Lazy: callback not called until get()\n  assert.strictEqual(computeCount, 0);\n\n  // First get() computes\n  assert.strictEqual(computed.get(), 3);\n  assert.strictEqual(computeCount, 1);\n\n  // Memoized: second get() returns cached value\n  assert.strictEqual(computed.get(), 3);\n  assert.strictEqual(computeCount, 1);\n});\n\nvoid test(\"ComputedSignal tracks dependencies\", () => {\n  // StateSignal dependencies\n  const a = new StateSignal(1);\n  const b = new StateSignal(2);\n  const sum = new ComputedSignal(() => a.get() + b.get());\n\n  assert.strictEqual(sum.get(), 3);\n  a.set(10);\n  assert.strictEqual(sum.get(), 12);\n  b.set(20);\n  assert.strictEqual(sum.get(), 30);\n\n  // ComputedSignal dependencies (chained)\n  const c = new StateSignal(2);\n  const doubled = new ComputedSignal(() => c.get() * 2);\n  const quadrupled = new ComputedSignal(() => doubled.get() * 2);\n\n  assert.strictEqual(quadrupled.get(), 8);\n  c.set(3);\n  assert.strictEqual(quadrupled.get(), 12);\n});\n\nvoid test(\"Dependencies can change between evaluations\", () => {\n  const condition = new StateSignal(true);\n  const a = new StateSignal(1);\n  const b = new StateSignal(2);\n\n  let computeCount = 0;\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    return condition.get() ? a.get() : b.get();\n  });\n\n  assert.strictEqual(computed.get(), 1);\n  assert.strictEqual(computeCount, 1);\n\n  // Changing a should trigger recompute\n  a.set(10);\n  assert.strictEqual(computed.get(), 10);\n  assert.strictEqual(computeCount, 2);\n\n  // Changing b should NOT trigger recompute (not a dependency)\n  b.set(20);\n  assert.strictEqual(computed.get(), 10);\n  assert.strictEqual(computeCount, 2);\n\n  // Switch condition - now b is dependency, a is not\n  condition.set(false);\n  assert.strictEqual(computed.get(), 20);\n  assert.strictEqual(computeCount, 3);\n\n  // Now changing a should NOT trigger recompute\n  a.set(100);\n  assert.strictEqual(computed.get(), 20);\n  assert.strictEqual(computeCount, 3);\n\n  // But changing b should\n  b.set(200);\n  assert.strictEqual(computed.get(), 200);\n  assert.strictEqual(computeCount, 4);\n});\n\nvoid test(\"Diamond dependency pattern (glitch-free with Checking optimization)\", () => {\n  //       A\n  //      / \\\n  //     B   C\n  //      \\ /\n  //       D\n  const a = new StateSignal(1);\n\n  let bCount = 0;\n  const b = new ComputedSignal(() => {\n    bCount++;\n    // Returns 10 for positive, 0 for non-positive\n    return a.get() > 0 ? 10 : 0;\n  });\n\n  let cCount = 0;\n  const c = new ComputedSignal(() => {\n    cCount++;\n    // Returns 20 for positive, 0 for non-positive\n    return a.get() > 0 ? 20 : 0;\n  });\n\n  let dCount = 0;\n  const d = new ComputedSignal(() => {\n    dCount++;\n    return b.get() + c.get();\n  });\n\n  // Initial computation\n  assert.strictEqual(d.get(), 30);\n  assert.strictEqual(bCount, 1);\n  assert.strictEqual(cCount, 1);\n  assert.strictEqual(dCount, 1);\n\n  // Change a, but b and c return same values - d should NOT recompute (Checking optimization)\n  a.set(2);\n  assert.strictEqual(d.get(), 30);\n  assert.strictEqual(bCount, 2);\n  assert.strictEqual(cCount, 2);\n  assert.strictEqual(dCount, 1); // d NOT recomputed\n\n  // Change a to negative - b and c return different values, d MUST recompute\n  a.set(-1);\n  assert.strictEqual(d.get(), 0);\n  assert.strictEqual(bCount, 3);\n  assert.strictEqual(cCount, 3);\n  assert.strictEqual(dCount, 2);\n});\n\nvoid test(\"Deeply nested computeds\", () => {\n  const state = new StateSignal(1);\n\n  // Create a chain of 100 computeds\n  let current: StateSignal<number> | ComputedSignal<number> = state;\n  for (let i = 0; i < 100; i++) {\n    const prev = current;\n    current = new ComputedSignal(() => prev.get() + 1);\n  }\n\n  assert.strictEqual(current.get(), 101);\n\n  state.set(0);\n  assert.strictEqual(current.get(), 100);\n});\n\n// =============================================================================\n// Error Handling\n// =============================================================================\n\nvoid test(\"ComputedSignal caches and rethrows errors\", () => {\n  let computeCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    throw new Error(\"test error\");\n  });\n\n  // First call throws\n  assert.throws(() => computed.get(), { message: \"test error\" });\n  assert.strictEqual(computeCount, 1);\n\n  // Second call throws cached error without recomputing\n  assert.throws(() => computed.get(), { message: \"test error\" });\n  assert.strictEqual(computeCount, 1);\n});\n\nvoid test(\"Error cache is cleared on dependency change\", () => {\n  const trigger = new StateSignal(0);\n  let shouldThrow = true;\n  let computeCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    trigger.get();\n    if (shouldThrow) throw new Error(\"test error\");\n    return 42;\n  });\n\n  // First call throws\n  assert.throws(() => computed.get(), { message: \"test error\" });\n  assert.strictEqual(computeCount, 1);\n\n  // Change dependency and fix the error condition\n  shouldThrow = false;\n  trigger.set(1);\n\n  // Now should succeed\n  assert.strictEqual(computed.get(), 42);\n  assert.strictEqual(computeCount, 2);\n});\n\nvoid test(\"Circular dependency throws error\", () => {\n  // Direct: a -> b -> a\n  // eslint-disable-next-line prefer-const\n  let aRef: ComputedSignal<number>;\n  const b = new ComputedSignal(() => aRef.get() + 1);\n  const a = new ComputedSignal(() => b.get() + 1);\n  aRef = a;\n\n  assert.throws(() => a.get(), { message: \"Circular dependency detected\" });\n\n  // Self-reference\n  // eslint-disable-next-line prefer-const\n  let selfRef: ComputedSignal<number>;\n  const self = new ComputedSignal(() => selfRef.get() + 1);\n  selfRef = self;\n\n  assert.throws(() => self.get(), { message: \"Circular dependency detected\" });\n});\n\n// =============================================================================\n// setTimeout Tests\n// =============================================================================\n\nvoid test(\"setTimeout outside computed behaves like regular setTimeout\", async () => {\n  let called = false;\n  setTimeout(() => {\n    called = true;\n  }, 10);\n\n  assert.strictEqual(called, false);\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n  assert.strictEqual(called, true);\n});\n\nvoid test(\"setTimeout inside computed fires when signal stays clean\", async () => {\n  const state = new StateSignal(1);\n  let callCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    state.get();\n    setTimeout(() => {\n      callCount++;\n    }, 10);\n    return \"done\";\n  });\n\n  computed.get();\n  assert.strictEqual(callCount, 0);\n\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n  assert.strictEqual(callCount, 1);\n});\n\nvoid test(\"setTimeout inside computed cancelled when signal becomes dirty\", async () => {\n  const state = new StateSignal(1);\n  let callCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    state.get();\n    setTimeout(() => {\n      callCount++;\n    }, 50);\n    return \"done\";\n  });\n\n  computed.get();\n\n  // Make the signal dirty before timeout fires - callback skipped via _isValid\n  state.set(2);\n\n  await new Promise((r) => globalThis.setTimeout(r, 100));\n  assert.strictEqual(callCount, 0);\n\n  // Recompute schedules a new timeout, old one was already skipped\n  computed.get();\n\n  await new Promise((r) => globalThis.setTimeout(r, 100));\n  assert.strictEqual(callCount, 1);\n});\n\nvoid test(\"setTimeout with Checking state\", async () => {\n  // Test: fires when Checking resolves to Clean (sources unchanged)\n  const stateA = new StateSignal(1);\n  const stateB = new StateSignal(100);\n\n  const intermediate = new ComputedSignal(() => {\n    stateA.get();\n    return \"constant\"; // Always returns same value\n  });\n\n  let callCount = 0;\n  const computed = new ComputedSignal(() => {\n    intermediate.get();\n    stateB.get();\n    setTimeout(() => {\n      callCount++;\n    }, 10);\n    return \"done\";\n  });\n\n  computed.get();\n  stateA.set(2); // intermediate recomputes but returns same value\n\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n  assert.strictEqual(callCount, 1); // Fires: Checking -> Clean\n\n  // Test: cancelled when Checking resolves to Dirty (sources changed)\n  const stateC = new StateSignal(1);\n  const intermediate2 = new ComputedSignal(() => stateC.get() * 2);\n\n  let callCount2 = 0;\n  const computed2 = new ComputedSignal(() => {\n    intermediate2.get();\n    setTimeout(() => {\n      callCount2++;\n    }, 10);\n    return \"done\";\n  });\n\n  computed2.get();\n  stateC.set(2); // intermediate2 returns different value\n\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n  assert.strictEqual(callCount2, 0); // Cancelled: Checking -> Dirty\n});\n\nvoid test(\"setTimeout passes arguments and can be manually cleared\", async () => {\n  // Test argument passing\n  let receivedArgs: unknown[] = [];\n  const computed = new ComputedSignal(() => {\n    setTimeout(\n      (a: number, b: string) => {\n        receivedArgs = [a, b];\n      },\n      10,\n      42,\n      \"hello\",\n    );\n    return \"done\";\n  });\n\n  computed.get();\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n  assert.deepStrictEqual(receivedArgs, [42, \"hello\"]);\n\n  // Test manual clearing\n  let called = false;\n  const computed2 = new ComputedSignal(() => {\n    const id = setTimeout(() => {\n      called = true;\n    }, 50);\n    globalThis.clearTimeout(id);\n    return \"done\";\n  });\n\n  computed2.get();\n  await new Promise((r) => globalThis.setTimeout(r, 100));\n  assert.strictEqual(called, false);\n});\n\n// =============================================================================\n// setInterval Tests\n// =============================================================================\n\nvoid test(\"setInterval outside computed behaves like regular setInterval\", async () => {\n  let callCount = 0;\n  const id = setInterval(() => {\n    callCount++;\n  }, 20);\n\n  await new Promise((r) => globalThis.setTimeout(r, 70));\n  globalThis.clearInterval(id);\n\n  assert.ok(callCount >= 2, `Expected at least 2 calls, got ${callCount}`);\n});\n\nvoid test(\"setInterval inside computed stops when signal becomes dirty\", async () => {\n  const state = new StateSignal(1);\n  let callCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    state.get();\n    setInterval(() => {\n      callCount++;\n    }, 20);\n    return \"done\";\n  });\n\n  computed.get();\n\n  // Let it fire once\n  await new Promise((r) => globalThis.setTimeout(r, 30));\n  const countAfterFirst = callCount;\n  assert.ok(countAfterFirst >= 1, \"Should have fired at least once\");\n\n  // Make the signal dirty\n  state.set(2);\n\n  // Wait for more potential intervals\n  await new Promise((r) => globalThis.setTimeout(r, 60));\n\n  // Should not have fired again (or at most once more if timing is tight)\n  assert.ok(\n    callCount <= countAfterFirst + 1,\n    `Expected no more than ${countAfterFirst + 1} calls, got ${callCount}`,\n  );\n});\n\nvoid test(\"setInterval inside computed stops and restarts on recompute\", async () => {\n  const state = new StateSignal(1);\n  let callCount = 0;\n  let intervalId: ReturnType<typeof setInterval>;\n\n  const computed = new ComputedSignal(() => {\n    const val = state.get();\n    intervalId = setInterval(() => {\n      callCount++;\n    }, 20);\n    return val;\n  });\n\n  computed.get();\n\n  // Let it fire a couple times\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n  const countBeforeRecompute = callCount;\n\n  // Recompute - old interval should stop, new one should start\n  state.set(2);\n  computed.get();\n\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n  globalThis.clearInterval(intervalId!);\n\n  // New interval should have fired\n  assert.ok(\n    callCount > countBeforeRecompute,\n    \"New interval should have fired after recompute\",\n  );\n});\n\nvoid test(\"setInterval passes arguments and can be manually cleared\", async () => {\n  // Test argument passing\n  let receivedArgs: unknown[] = [];\n  const id = setInterval(\n    (a: number, b: string) => {\n      receivedArgs = [a, b];\n    },\n    10,\n    42,\n    \"hello\",\n  );\n\n  await new Promise((r) => globalThis.setTimeout(r, 30));\n  globalThis.clearInterval(id);\n  assert.deepStrictEqual(receivedArgs, [42, \"hello\"]);\n\n  // Test manual clearing\n  let callCount = 0;\n  const computed = new ComputedSignal(() => {\n    const intervalId = setInterval(() => {\n      callCount++;\n    }, 20);\n    globalThis.clearInterval(intervalId);\n    return \"done\";\n  });\n\n  computed.get();\n  await new Promise((r) => globalThis.setTimeout(r, 70));\n  assert.strictEqual(callCount, 0);\n});\n\n// =============================================================================\n// Disposal Tests\n// =============================================================================\n\nvoid test(\"ConstSignal disposal\", () => {\n  const signal = new ConstSignal(42);\n  assert.strictEqual(signal.get(), 42);\n\n  signal[Symbol.dispose]();\n\n  // Reading after disposal throws\n  assert.throws(() => signal.get(), { message: \"Cannot read disposed signal\" });\n\n  // Disposing again is a no-op (doesn't throw)\n  signal[Symbol.dispose]();\n});\n\nvoid test(\"StateSignal disposal\", () => {\n  const signal = new StateSignal(42);\n  assert.strictEqual(signal.get(), 42);\n\n  signal[Symbol.dispose]();\n\n  // Reading after disposal throws\n  assert.throws(() => signal.get(), { message: \"Cannot read disposed signal\" });\n\n  // Writing after disposal throws\n  assert.throws(() => signal.set(100), {\n    message: \"Cannot write to disposed signal\",\n  });\n\n  // Disposing again is a no-op (doesn't throw)\n  signal[Symbol.dispose]();\n});\n\nvoid test(\"ComputedSignal disposal\", () => {\n  const state = new StateSignal(1);\n  let computeCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    return state.get() * 2;\n  });\n\n  assert.strictEqual(computed.get(), 2);\n  assert.strictEqual(computeCount, 1);\n\n  computed[Symbol.dispose]();\n\n  // Reading after disposal throws\n  assert.throws(() => computed.get(), {\n    message: \"Cannot read disposed signal\",\n  });\n\n  // Disposing again is a no-op (doesn't throw)\n  computed[Symbol.dispose]();\n\n  // Source state still works\n  assert.strictEqual(state.get(), 1);\n});\n\nvoid test(\"ComputedSignal disposal detaches from sources\", () => {\n  const state = new StateSignal(1);\n  let computeCount = 0;\n\n  const computed = new ComputedSignal(() => {\n    computeCount++;\n    return state.get() * 2;\n  });\n\n  assert.strictEqual(computed.get(), 2);\n  assert.strictEqual(computeCount, 1);\n\n  // Verify sink is registered\n  assert.strictEqual((state as any)._sinks.size, 1);\n\n  computed[Symbol.dispose]();\n\n  // Sink should be removed after disposal\n  assert.strictEqual((state as any)._sinks.size, 0);\n});\n\nvoid test(\"ComputedSignal disposal runs cleanups\", async () => {\n  let timeoutFired = false;\n  let intervalFired = false;\n\n  const computed = new ComputedSignal(() => {\n    setTimeout(() => {\n      timeoutFired = true;\n    }, 10);\n    setInterval(() => {\n      intervalFired = true;\n    }, 10);\n    return \"done\";\n  });\n\n  computed.get();\n  computed[Symbol.dispose]();\n\n  // Wait for timers that would have fired\n  await new Promise((r) => globalThis.setTimeout(r, 50));\n\n  // Neither should have fired because disposal cleared them\n  assert.strictEqual(timeoutFired, false);\n  assert.strictEqual(intervalFired, false);\n});\n\nvoid test(\"Disposal cascades to nested signals of all types\", () => {\n  let innerState: StateSignal<number> | null = null;\n  let innerConst: ConstSignal<number> | null = null;\n  let innerComputed: ComputedSignal<number> | null = null;\n\n  const outer = new ComputedSignal(() => {\n    innerState = new StateSignal(10);\n    innerConst = new ConstSignal(20);\n    innerComputed = new ComputedSignal(() => 30);\n    return innerState.get() + innerConst.get() + innerComputed.get();\n  });\n\n  assert.strictEqual(outer.get(), 60);\n\n  outer[Symbol.dispose]();\n\n  assert.throws(() => innerState!.get(), {\n    message: \"Cannot read disposed signal\",\n  });\n  assert.throws(() => innerConst!.get(), {\n    message: \"Cannot read disposed signal\",\n  });\n  assert.throws(() => innerComputed!.get(), {\n    message: \"Cannot read disposed signal\",\n  });\n});\n\n// =============================================================================\n// Watcher Tests\n// =============================================================================\n\nvoid test(\"Watcher notifies on state change and at most once per batch\", () => {\n  const state = new StateSignal(1);\n  let notifyCount = 0;\n  const watcher = new Watcher(() => {\n    notifyCount++;\n  });\n  watcher.watch(state);\n\n  // Fires on change\n  state.set(2);\n  assert.strictEqual(notifyCount, 1);\n\n  // At-most-once: second change in same batch does not fire again\n  state.set(3);\n  assert.strictEqual(notifyCount, 1);\n\n  // watch() resets the flag, allowing notification again\n  watcher.watch(state);\n  state.set(4);\n  assert.strictEqual(notifyCount, 2);\n\n  watcher[Symbol.dispose]();\n});\n\nvoid test(\"Watcher notifies on transitive dependency change\", () => {\n  const state = new StateSignal(1);\n  const computed = new ComputedSignal(() => state.get() * 2);\n  let notifyCount = 0;\n  const watcher = new Watcher(() => {\n    notifyCount++;\n  });\n\n  // Prime the computed so it registers dependencies and is Clean\n  computed.get();\n  watcher.watch(computed);\n\n  // Changing the root state propagates through the computed to the watcher\n  state.set(2);\n  assert.strictEqual(notifyCount, 1);\n\n  watcher[Symbol.dispose]();\n});\n\nvoid test(\"Watcher unwatch and disposal stop notifications\", () => {\n  const a = new StateSignal(1);\n  const b = new StateSignal(1);\n  let notifyCount = 0;\n  const watcher = new Watcher(() => {\n    notifyCount++;\n  });\n  watcher.watch(a, b);\n\n  // Unwatch a, changes to a no longer notify\n  watcher.unwatch(a);\n  a.set(2);\n  assert.strictEqual(notifyCount, 0);\n\n  // b still notifies\n  b.set(2);\n  assert.strictEqual(notifyCount, 1);\n\n  // After disposal, nothing notifies\n  watcher.watch(b); // reset notified flag\n  watcher[Symbol.dispose]();\n  b.set(3);\n  assert.strictEqual(notifyCount, 1);\n});\n\nvoid test(\"Watcher getPending returns dirty computed signals\", () => {\n  const state = new StateSignal(1);\n  const computed = new ComputedSignal(() => state.get() * 2);\n\n  // Prime the computed so it has registered dependencies\n  computed.get();\n\n  const watcher = new Watcher(() => {});\n  watcher.watch(computed);\n\n  // Before any change, nothing is pending\n  assert.deepStrictEqual(watcher.getPending(), []);\n\n  // After change, computed is pending\n  state.set(2);\n  assert.deepStrictEqual(watcher.getPending(), [computed]);\n\n  // After reading, no longer pending\n  computed.get();\n  assert.deepStrictEqual(watcher.getPending(), []);\n\n  watcher[Symbol.dispose]();\n});\n"
  },
  {
    "path": "test/synth.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport initSqlJs from \"sql.js/dist/sql-asm.js\";\nimport {\n  covers,\n  minimize,\n  unionDiff,\n  subtract,\n  areEquivalent,\n} from \"../lib/common/expression/synth.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nfunction isFalse(expr: Expression): boolean {\n  return expr instanceof Expression.Literal && expr.value === false;\n}\n\nconst STRING_VALUES = [null, \"\", \"a\", \"ab\", \"ab10\", \"ab-10\"];\nconst DECIMAL_VALUES = [null, 0, -10, 10];\n\nlet db;\n\nasync function query(filter: string): Promise<Set<number>> {\n  if (!db) {\n    const sql = await initSqlJs();\n    db = new sql.Database();\n\n    db.run(\n      \"CREATE TABLE test (id INTEGER PRIMARY KEY, string STRING, decimal DECIMAL(4,2))\",\n    );\n\n    const stmt = db.prepare(\"INSERT INTO test (string, decimal) VALUES (?, ?)\");\n    const count = STRING_VALUES.length * DECIMAL_VALUES.length;\n    for (let i = 0; i < count; ++i) {\n      const str = i % STRING_VALUES.length;\n      const dec = Math.trunc(i / STRING_VALUES.length) % DECIMAL_VALUES.length;\n      stmt.run([STRING_VALUES[str], DECIMAL_VALUES[dec]]);\n    }\n    stmt.free();\n  }\n\n  const res = db.exec(`SELECT id FROM test WHERE ${filter}`);\n  if (!res.length) return new Set();\n  return new Set(res[0].values.flat());\n}\n\nfunction setsEqual(set1: Set<number>, set2: Set<number>): boolean {\n  if (set1.size !== set2.size) return false;\n  for (const s of set1) if (!set2.has(s)) return false;\n  return true;\n}\n\nfunction getPermutations(...arrs: any[][]): any[][] {\n  const count = arrs.reduce((total, arr) => total * arr.length, 1);\n  const res = [];\n  for (let i = 0; i < count; ++i) {\n    let j = i;\n    const row = [];\n    for (const arr of arrs) {\n      const v = arr[j % arr.length];\n      j = Math.trunc(j / arr.length);\n      row.push(v);\n    }\n    res.push(row);\n  }\n  return res;\n}\n\nvoid test(\"minimize\", async () => {\n  const cases: string[] = [];\n\n  cases.push(\"null\");\n  cases.push(\"false\");\n  cases.push(\"true\");\n  cases.push(\"string\");\n  cases.push(\"(string + decimal) IS NULL\");\n  cases.push(\"(string + decimal) = NULL\");\n  cases.push(\"COALESCE(string, decimal) = 0\");\n\n  for (const [s1, s2, s3, op1, op2] of getPermutations(\n    STRING_VALUES.filter((s) => s),\n    STRING_VALUES.filter((s) => s),\n    STRING_VALUES.filter((s) => s),\n    [\">\", \"=\", \"<\"],\n    [\"<>\", \">=\"],\n  )) {\n    cases.push(\n      `string ${op1} \"${s1}\" OR string ='${s2}' OR NOT string ${op2} '${s3}'`,\n    );\n  }\n\n  for (const [s1, s2] of getPermutations(\n    STRING_VALUES.filter((s) => s),\n    STRING_VALUES.filter((s) => s),\n  ))\n    cases.push(`string > \"${s1}\" AND string < '${s2}'`);\n\n  for (const c of cases) {\n    const res1 = await query(c);\n    const min = minimize(Expression.parse(c), true).toString();\n    const res2 = await query(min);\n    assert.strictEqual(setsEqual(res1, res2), true);\n  }\n});\n\nvoid test(\"unionDiff\", async () => {\n  const cases = [\n    \"true\",\n    \"decimal > 0\",\n    \"decimal > 10\",\n    \"UPPER(string || decimal) LIKE 'AB10'\",\n    \"COALESCE(string, decimal) = 0\",\n  ];\n\n  for (const [c1, c2] of getPermutations(cases, cases)) {\n    const res1 = await query(c1);\n    const res2 = await query(c2);\n    const [union, diff] = unionDiff(Expression.parse(c1), Expression.parse(c2));\n    const res3 = await query(union.toString());\n    const res4 = await query(diff.toString());\n\n    const unionSet = new Set([...res1, ...res2]);\n    const diffSet = new Set(Array.from(res2).filter((r) => !res1.has(r)));\n\n    assert.strictEqual(setsEqual(res3, unionSet), true);\n    assert.strictEqual(setsEqual(res4, diffSet), true);\n  }\n});\n\nvoid test(\"covers\", async () => {\n  assert.strictEqual(\n    covers(Expression.parse(\"false\"), Expression.parse(\"false\")),\n    true,\n  );\n  assert.strictEqual(\n    covers(\n      Expression.parse(\"false\"),\n      Expression.parse(\"decimal > 5 AND decimal < 3\"),\n    ),\n    true,\n  );\n  assert.strictEqual(\n    covers(Expression.parse(\"true\"), Expression.parse(\"decimal > 0\")),\n    true,\n  );\n  assert.strictEqual(\n    covers(Expression.parse(\"true\"), Expression.parse(\"false\")),\n    true,\n  );\n  assert.strictEqual(\n    covers(Expression.parse(\"false\"), Expression.parse(\"decimal > 0\")),\n    false,\n  );\n  assert.strictEqual(\n    covers(Expression.parse(\"decimal >= 0\"), Expression.parse(\"decimal > 0\")),\n    true,\n  );\n  assert.strictEqual(\n    covers(Expression.parse(\"decimal > 0\"), Expression.parse(\"decimal >= 0\")),\n    false,\n  );\n\n  const cases = [\n    [\"decimal >= 0\", \"decimal > 0\"],\n    [\"decimal > 0\", \"decimal > 5\"],\n    [\"string IS NOT NULL\", \"string = 'a'\"],\n    [\"true\", \"decimal > 0\"],\n  ];\n\n  for (const [c1, c2] of cases) {\n    const res1 = await query(c1);\n    const res2 = await query(c2);\n    const coversResult = covers(Expression.parse(c1), Expression.parse(c2));\n    const actuallyCovers = Array.from(res2).every((r) => res1.has(r));\n\n    assert.strictEqual(\n      coversResult,\n      actuallyCovers,\n      `covers(${c1}, ${c2}) should match actual coverage`,\n    );\n  }\n});\n\nvoid test(\"LIKE-Compare DC set relationships\", () => {\n  const likeExpr = Expression.parse(\"string LIKE 'a%'\");\n\n  const eqExpr = Expression.parse(\"string = 'a'\");\n  const conjExpr: Expression = new Expression.Binary(\"AND\", eqExpr, likeExpr);\n  assert.strictEqual(\n    isFalse(minimize(conjExpr, true)),\n    false,\n    \"(string = 'a') AND (string LIKE 'a%') should NOT minimize to false\",\n  );\n\n  const nonMatchingExpr = Expression.parse(\"string = 'b'\");\n  const conjNonMatch: Expression = new Expression.Binary(\n    \"AND\",\n    nonMatchingExpr,\n    likeExpr,\n  );\n  assert.strictEqual(\n    isFalse(minimize(conjNonMatch, true)),\n    true,\n    \"(string = 'b') AND (string LIKE 'a%') should minimize to false\",\n  );\n});\n\nvoid test(\"LIKE-Compare DC set with range operators\", () => {\n  const likeExpr = Expression.parse(\"string LIKE 'abc%'\");\n\n  const ltExpr = Expression.parse(\"string < 'abc'\");\n  const ltConj: Expression = new Expression.Binary(\"AND\", ltExpr, likeExpr);\n  assert.strictEqual(\n    isFalse(minimize(ltConj, true)),\n    true,\n    \"(string < 'abc') AND (string LIKE 'abc%') should be false\",\n  );\n\n  const ltExpr2 = Expression.parse(\"string < 'abd'\");\n  const ltConj2: Expression = new Expression.Binary(\"AND\", ltExpr2, likeExpr);\n  assert.strictEqual(\n    isFalse(minimize(ltConj2, true)),\n    false,\n    \"(string < 'abd') AND (string LIKE 'abc%') should NOT be false\",\n  );\n\n  const gtExpr = Expression.parse(\"string > 'abd'\");\n  const gtConj: Expression = new Expression.Binary(\"AND\", gtExpr, likeExpr);\n  assert.strictEqual(\n    isFalse(minimize(gtConj, true)),\n    true,\n    \"(string > 'abd') AND (string LIKE 'abc%') should be false\",\n  );\n\n  const gtExpr2 = Expression.parse(\"string > 'abc'\");\n  const gtConj2: Expression = new Expression.Binary(\"AND\", gtExpr2, likeExpr);\n  assert.strictEqual(\n    isFalse(minimize(gtConj2, true)),\n    false,\n    \"(string > 'abc') AND (string LIKE 'abc%') should NOT be false\",\n  );\n});\n\nvoid test(\"subtract returns same result as unionDiff diff\", async () => {\n  const cases = [\n    \"true\",\n    \"decimal > 0\",\n    \"decimal > 10\",\n    \"UPPER(string || decimal) LIKE 'AB10'\",\n    \"COALESCE(string, decimal) = 0\",\n  ];\n\n  for (const [c1, c2] of getPermutations(cases, cases)) {\n    const res1 = await query(c1);\n    const res2 = await query(c2);\n    const diffExpr = subtract(Expression.parse(c1), Expression.parse(c2));\n    const resDiff = await query(diffExpr.toString());\n\n    const expectedDiff = new Set(Array.from(res2).filter((r) => !res1.has(r)));\n    assert.strictEqual(\n      setsEqual(resDiff, expectedDiff),\n      true,\n      `subtract(${c1}, ${c2}) should equal expr2 - expr1`,\n    );\n  }\n});\n\nvoid test(\"areEquivalent\", async () => {\n  // Equivalent expressions\n  assert.strictEqual(\n    areEquivalent(Expression.parse(\"true\"), Expression.parse(\"true\")),\n    true,\n  );\n  assert.strictEqual(\n    areEquivalent(Expression.parse(\"false\"), Expression.parse(\"false\")),\n    true,\n  );\n  assert.strictEqual(\n    areEquivalent(\n      Expression.parse(\"decimal > 0\"),\n      Expression.parse(\"decimal > 0\"),\n    ),\n    true,\n  );\n\n  // Logically equivalent but syntactically different\n  assert.strictEqual(\n    areEquivalent(\n      Expression.parse(\"NOT decimal <= 0\"),\n      Expression.parse(\"decimal > 0\"),\n    ),\n    true,\n  );\n  assert.strictEqual(\n    areEquivalent(\n      Expression.parse(\"decimal > 0 OR decimal = 0\"),\n      Expression.parse(\"decimal >= 0\"),\n    ),\n    true,\n  );\n\n  // Non-equivalent expressions\n  assert.strictEqual(\n    areEquivalent(\n      Expression.parse(\"decimal > 0\"),\n      Expression.parse(\"decimal >= 0\"),\n    ),\n    false,\n  );\n  assert.strictEqual(\n    areEquivalent(Expression.parse(\"true\"), Expression.parse(\"false\")),\n    false,\n  );\n\n  // Nullable expression tests - these test sanitization\n  // decimal > 0 implies decimal IS NOT NULL\n  assert.strictEqual(\n    areEquivalent(\n      Expression.parse(\"decimal > 0\"),\n      Expression.parse(\"decimal > 0 AND decimal IS NOT NULL\"),\n    ),\n    true,\n    \"decimal > 0 should be equivalent to (decimal > 0 AND decimal IS NOT NULL)\",\n  );\n\n  // Non-equivalent nullable expressions\n  assert.strictEqual(\n    areEquivalent(\n      Expression.parse(\"decimal > 0\"),\n      Expression.parse(\"decimal > 0 OR decimal IS NULL\"),\n    ),\n    false,\n    \"decimal > 0 should NOT be equivalent to (decimal > 0 OR decimal IS NULL)\",\n  );\n\n  // Complex nullable expression - De Morgan with nullable\n  assert.strictEqual(\n    areEquivalent(\n      Expression.parse(\"NOT (decimal > 0 OR decimal IS NULL)\"),\n      Expression.parse(\"decimal <= 0 AND decimal IS NOT NULL\"),\n    ),\n    true,\n    \"De Morgan with nullable should work\",\n  );\n});\n"
  },
  {
    "path": "test/util.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport * as common from \"../lib/util.ts\";\n\nvoid test(\"generateDeviceId\", () => {\n  const space = [\" \", \"%20\"];\n  const special = [\";\", \"%3B\"];\n  const cases = [\n    [\n      {\n        ProductClass: \"TestProductClass\",\n        OUI: \"TestOUI\",\n        SerialNumber: \"TestSerialNumber\",\n      },\n      \"TestOUI-TestProductClass-TestSerialNumber\",\n    ],\n    [\n      {\n        OUI: \"TestOUI\",\n        SerialNumber: \"TestSerialNumber\",\n      },\n      \"TestOUI-TestSerialNumber\",\n    ],\n    [\n      {\n        OUI: `TestOUIWith${space[0]}_${special[0]}2912`,\n        SerialNumber: `TestSerialNumberWith${space[0]}_${special[0]}2912`,\n      },\n      `TestOUIWith${space[1]}_${special[1]}2912-TestSerialNumberWith${space[1]}_${special[1]}2912`,\n    ],\n  ];\n\n  for (const c of cases)\n    assert.strictEqual(\n      common.generateDeviceId(c[0] as Record<string, string>),\n      c[1],\n    );\n});\n\nvoid test(\"escapeRegExp\", () => {\n  assert.strictEqual(\n    common.escapeRegExp(\"\\\\ ^ $ * + ? . ( ) | { } [ ]\"),\n    \"\\\\\\\\ \\\\^ \\\\$ \\\\* \\\\+ \\\\? \\\\. \\\\( \\\\) \\\\| \\\\{ \\\\} \\\\[ \\\\]\",\n  );\n});\n"
  },
  {
    "path": "test/xml-parser.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport {\n  parseXmlDeclaration,\n  decodeEntities,\n  parseXml,\n} from \"../lib/xml-parser.ts\";\n\nvoid test(\"parseXmlDeclaration\", () => {\n  const buf = Buffer.from(\n    '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n<soap-env:Envelope />',\n  );\n  const attrs = parseXmlDeclaration(buf);\n  assert.deepStrictEqual(attrs, [\n    {\n      name: \"version\",\n      namespace: \"\",\n      localName: \"version\",\n      value: \"1.0\",\n    },\n    {\n      name: \"encoding\",\n      namespace: \"\",\n      localName: \"encoding\",\n      value: \"UTF-8\",\n    },\n  ]);\n});\n\nvoid test(\"decodeEntities\", () => {\n  assert.strictEqual(\n    decodeEntities(\"&&amp;&lt;&gt;&quot;&apos;&gt;&#167;&#xd842;&#xDFB7;;\"),\n    \"&&<>\\\"'>§𠮷;\",\n  );\n});\n\nvoid test(\"parse\", () => {\n  const xml =\n    '<?xml version=\"1.0\"?>\\n<a-b:c><d f=\"1<g>\"/><!-- comment --><h >i</h></a-b:c>';\n  const parsed = parseXml(xml);\n  assert.deepStrictEqual(parsed, {\n    name: \"root\",\n    namespace: \"\",\n    localName: \"root\",\n    attrs: \"\",\n    text: \"\",\n    bodyIndex: 0,\n    children: [\n      {\n        name: \"a-b:c\",\n        namespace: \"a-b\",\n        localName: \"c\",\n        attrs: \"\",\n        text: \"\",\n        bodyIndex: 29,\n        children: [\n          {\n            name: \"d\",\n            namespace: \"\",\n            localName: \"d\",\n            attrs: 'f=\"1<g>\"',\n            text: \"\",\n            bodyIndex: 42,\n            children: [],\n          },\n          {\n            name: \"h\",\n            namespace: \"\",\n            localName: \"h\",\n            attrs: \"\",\n            text: \"i\",\n            bodyIndex: 62,\n            children: [],\n          },\n        ],\n      },\n    ],\n  });\n});\n"
  },
  {
    "path": "test/yaml-tests.json",
    "content": "[\n  [\n    {\n      \"name\": \"Mark McGwire\",\n      \"hr\": 65,\n      \"avg\": 0.278\n    },\n    {\n      \"name\": \"Sammy Sosa\",\n      \"hr\": 63,\n      \"avg\": 0.288\n    }\n  ],\n  {\n    \"top1\": {\n      \"key1\": \"scalar1\"\n    },\n    \"top2\": {\n      \"key2\": \"scalar2\"\n    },\n    \"top3\": {\n      \"scalar1\": \"scalar3\"\n    },\n    \"top4\": {\n      \"scalar2\": \"scalar4\"\n    },\n    \"top5\": \"scalar5\",\n    \"top6\": {\n      \"key6\": \"scalar6\"\n    }\n  },\n  \"text\",\n  [\"a\", \"b\", 42, \"d\"],\n  {\n    \"a!\\\"#$%&'()*+,-./09:;<=>?@AZ[\\\\]^_`az{|}~\": \"safe\",\n    \"?foo\": \"safe question mark\",\n    \":foo\": \"safe colon\",\n    \"-foo\": \"safe dash\",\n    \"this is#not\": \"a comment\"\n  },\n  {\n    \"\": \"a\"\n  },\n  \"foo\",\n  {\n    \"key\": \"value\",\n    \"foo\": \"key\"\n  },\n  [1, -2, 33],\n  {\n    \"plain\": \"a b\\nc\"\n  },\n  [[\"s1_i1\", \"s1_i2\"], \"s2\"],\n  {\n    \"First occurrence\": \"Foo\",\n    \"Second occurrence\": \"Foo\",\n    \"Override anchor\": \"Bar\",\n    \"Reuse anchor\": \"Bar\"\n  },\n  \"k:#foo &a !t s\",\n  [\"a\"],\n  {\n    \"escaped slash\": \"a/b\"\n  },\n  {\n    \"plain\": \"This unquoted scalar spans many lines.\",\n    \"quoted\": \"So does this quoted scalar.\\n\"\n  },\n  \"here's to \\\"quotes\\\"\",\n  {\n    \"foo\": \"bar\"\n  },\n  \"ab cd\\nef\\n\\ngh\\n\",\n  [\"detected\\n\", \"\\n\\n# detected\\n\", \" explicit\\n\", \"detected\\n\"],\n  \"foo: bar\\\": baz\",\n  \"plain\\\\value\\\\with\\\\backslashes\",\n  {\n    \"plain\": \"text lines\",\n    \"quoted\": \"text lines\",\n    \"block\": \"text\\n \\tlines\\n\"\n  },\n  \"a\",\n  {\n    \"foo\": \"you\",\n    \"bar\": \"far\"\n  },\n  {\n    \"sequence\": [\"entry\", [\"nested\"]],\n    \"mapping\": {\n      \"foo\": \"bar\"\n    }\n  },\n  {\n    \"literal\": \"some\\ntext\\n\",\n    \"folded\": \"some text\\n\"\n  },\n  [\n    {\n      \"one\": \"two\",\n      \"three\": \"four\"\n    },\n    {\n      \"five\": \"six\",\n      \"seven\": \"eight\"\n    }\n  ],\n  {\n    \"Folding\": \"Empty line\\nas a line feed\",\n    \"Chomping\": \"Clipped empty lines\\n\"\n  },\n  [\n    [\"one\", \"two\"],\n    [\"three\", \"four\"]\n  ],\n  {\n    \"foo\": \"bar\"\n  },\n  {\n    \"key\": \"value\"\n  },\n  {\n    \"explicit key\": null,\n    \"block key\\n\": [\"one\", \"two\"]\n  },\n  [\"foo\"],\n  [\n    {\n      \"foo\": \"bar\"\n    },\n    [\"baz\", \"baz\"]\n  ],\n  \"ab\\n\\n \\n\",\n  {\n    \"foo: bar\\\\\": \"baz'\"\n  },\n  {\n    \"Not indented\": {\n      \"By one space\": \"By four\\n  spaces\\n\",\n      \"Flow style\": [\"By two\", \"Also by two\", \"Still by two\"]\n    }\n  },\n  \"\\\\//||\\\\/||\\n// ||  ||__\\n\",\n  {\n    \"foo\": [\n      \"a\",\n      {\n        \"key\": \"value\"\n      }\n    ]\n  },\n  {\n    \"a\": null,\n    \"b\": null\n  },\n  \"foo\",\n  {\n    \"a\": \"b\",\n    \"\": \"a\"\n  },\n  {\n    \"foo\\nbar:baz\\tx \\\\$%^&*()x\": 23,\n    \"x\\\\ny:z\\\\tx $%^&*()x\": 24\n  },\n  \"Sammy Sosa completed another fine season with great stats.\\n\\n  63 Home Runs\\n  0.288 Batting Average\\n\\nWhat a year!\\n\",\n  \" foo\\nbar\\nbaz \",\n  { \"matches %\": 20 },\n  [\n    \"flow in block\",\n    \"Block scalar\\n\",\n    {\n      \"foo\": \"bar\"\n    }\n  ],\n  {\n    \"a\": \"b\",\n    \"c\": 42,\n    \"e\": \"f\",\n    \"g\": \"h\",\n    \"23\": false\n  },\n  \"ab\",\n  \" 1st non-empty\\n2nd non-empty 3rd non-empty \",\n  {\n    \"top1\": {\n      \"key1\": \"one\"\n    },\n    \"top2\": {\n      \"key2\": \"two\"\n    },\n    \"top3\": {\n      \"key3\": \"three\"\n    },\n    \"top4\": {\n      \"key4\": \"four\"\n    },\n    \"top5\": {\n      \"key5\": \"five\"\n    },\n    \"top6\": \"six\",\n    \"top7\": \"seven\"\n  },\n  {\n    \"hr\": [\"Mark McGwire\", \"Sammy Sosa\"],\n    \"rbi\": [\"Sammy Sosa\", \"Ken Griffey\"]\n  },\n  \"\\nfolded line\\nnext line\\n  * bullet\\n\\n  * list\\n  * lines\\n\\nlast line\\n\",\n  [\"word1\", \"word2\"],\n  {\n    \"a\": null,\n    \"b\": null,\n    \"c\": null\n  },\n  {\n    \"nested sequences\": [[[[]]], [[{}]]],\n    \"key1\": [],\n    \"key2\": {}\n  },\n  \"---word1 word2\",\n  {\n    \"implicit block key\": [\n      {\n        \"implicit flow key\": \"value\"\n      }\n    ]\n  },\n  {\n    \"key ends with two colons::\": \"value\"\n  },\n  [\n    {\n      \"single line\": null,\n      \"a\": \"b\"\n    },\n    {\n      \"multi line\": null,\n      \"a\": \"b\"\n    }\n  ],\n  \"a\",\n  {\n    \"key\": [\"item1\", \"item2\"]\n  },\n  [\n    \"double quoted\",\n    \"single quoted\",\n    \"plain text\",\n    [\"nested\"],\n    {\n      \"single\": \"pair\"\n    }\n  ],\n  [\"unicode anchor\"],\n  [\n    {\n      \"key\": \"value\",\n      \"key2\": \"value2\"\n    },\n    {\n      \"key3\": \"value3\"\n    }\n  ],\n  \"trimmed\\n\\n\\nas space\",\n  \"Mark McGwire's year was crippled by a knee injury.\\n\",\n  [\n    {\n      \"single line\": null,\n      \"a\": \"b\"\n    },\n    {\n      \"multi line\": null,\n      \"a\": \"b\"\n    }\n  ],\n  {\n    \"a\": {\n      \"b\": {\n        \"c\": \"d\"\n      },\n      \"e\": {\n        \"f\": \"g\"\n      }\n    },\n    \"h\": \"i\"\n  },\n  {\n    \"foo\": {\n      \"bar\": \"baz\"\n    }\n  },\n  [\n    {\n      \"single line\": \"value\"\n    },\n    {\n      \"multi line\": \"value\"\n    }\n  ],\n  {\n    \"single\": \"text\",\n    \"double\": \"text\"\n  },\n  \" 1st non-empty\\n2nd non-empty 3rd non-empty \",\n  [\n    {\n      \"item\": \"Super Hoop\",\n      \"quantity\": 1\n    },\n    {\n      \"item\": \"Basketball\",\n      \"quantity\": 4\n    },\n    {\n      \"item\": \"Big Shoes\",\n      \"quantity\": 1\n    }\n  ],\n  \"a b c d\\ne\",\n  {\n    \"a\": [\"b\", [\"c\", \"d\"]]\n  },\n  {\n    \"strip\": \"text\",\n    \"clip\": \"text\\n\",\n    \"keep\": \"text\\n\"\n  },\n  {\n    \"a\": \"b c\",\n    \"d\": \"e f\"\n  },\n  [\"single multiline - sequence entry\"],\n  {\n    \"1\": [2, 3],\n    \"4\": 5\n  },\n  [\n    {\n      \"bla\\\"keks\": \"foo\"\n    },\n    {\n      \"bla]keks\": \"foo\"\n    }\n  ],\n  \"folded text\\n\",\n  \"foo\",\n  {\n    \"key\": {\n      \"a\": \"b\"\n    }\n  },\n  {\n    \"adjacent\": \"value\",\n    \"readable\": \"value\",\n    \"empty\": null\n  },\n  [\n    {\n      \"a\": \"b\"\n    },\n    {\n      \"c\": \"d\"\n    },\n    {\n      \"e\": \"f\"\n    },\n    {\n      \"g\": \"h\"\n    }\n  ],\n  {\n    \"tab\": \"\\tstring\"\n  },\n  [\n    {\n      \"foo bar\": \"baz\"\n    }\n  ],\n  {\n    \"anchored\": \"value\",\n    \"alias\": \"value\"\n  },\n  [\"explicit indent and chomp\", \"chomp and explicit indent\"],\n  {\n    \"a\": [\"b\", \"c\"]\n  },\n  {\n    \"foo\": \"bar\"\n  },\n  [\n    \"::vector\",\n    \": - ()\",\n    \"Up, up, and away!\",\n    -123,\n    \"http://example.com/foo#bar\",\n    [\n      \"::vector\",\n      \": - ()\",\n      \"Up, up and away!\",\n      -123,\n      \"http://example.com/foo#bar\"\n    ]\n  ],\n  {\n    \"a\": \"b\",\n    \"seq\": [\"a\"],\n    \"c\": \"d\"\n  },\n  [\"foo\", \"bar\", 42],\n  \"line1 # no comment line3\\n\",\n  \"\\n\\nliteral\\n \\n\\ntext\\n\",\n  {\n    \"a\": \"b\"\n  },\n  {\n    \"k\": [\"a\", \"b\"]\n  },\n  \"a b c d\\ne\",\n  \"---word1 word2\",\n  [\"a\", 2, 4, \"d\"],\n  {\n    \"a\": [\n      \"b\",\n      \"c\",\n      {\n        \"d\": [\"e\", \"f\"]\n      }\n    ]\n  },\n  {\n    \"a\": \" more indented\\nregular\\n\",\n    \"b\": \"\\n\\n more indented\\nregular\\n\"\n  },\n  {\n    \"strip\": \"# text\",\n    \"clip\": \"# text\\n\",\n    \"keep\": \"# text\\n\\n\"\n  },\n  {\n    \"safe\": \"a!\\\"#$%&'()*+,-./09:;<=>?@AZ[\\\\]^_`az{|}~ !\\\"#$%&'()*+,-./09:;<=>?@AZ[\\\\]^_`az{|}~\",\n    \"safe question mark\": \"?foo\",\n    \"safe colon\": \":foo\",\n    \"safe dash\": \"-foo\"\n  },\n  \"line1 line2 line3\\n\",\n  [\"Mark McGwire\", \"Sammy Sosa\", \"Ken Griffey\"],\n  [\"a\"],\n  [\"a\", [\"b\", \"c\"]],\n  {\n    \"unicode\": \"Sosa did fine.☺\",\n    \"control\": \"\\b1998\\t1999\\t2000\\n\",\n    \"hex esc\": \"\\r\\n is \\r\\n\",\n    \"single\": \"\\\"Howdy!\\\" he cried.\",\n    \"quoted\": \" # Not a 'comment'.\",\n    \"tie-fighter\": \"|\\\\-*-/|\"\n  },\n  [[\"-\", \"-\"]],\n  \"folded text\\n\",\n  {\n    \"a\": 13,\n    \"1.5\": \"d\"\n  },\n  {\n    \"foo\": 1,\n    \"bar\": 2,\n    \"text\": \"a\\n  \\nb\\n\\nc\\n\\nd\\n\"\n  },\n  {\n    \"wanted\": \"love ♥ and peace ☮\"\n  },\n  {\n    \"name\": \"Mark McGwire\",\n    \"accomplishment\": \"Mark set a major league home run record in 1998.\\n\",\n    \"stats\": \"65 Home Runs\\n0.278 Batting Average\\n\"\n  },\n  {\n    \"foo\": \"bar\",\n    \"baz\": \"foo\"\n  },\n  \"1st non-empty\\n2nd non-empty 3rd non-empty\",\n  {\n    \"quoted\": \"Quoted \\t\",\n    \"block\": \"void main() {\\n\\tprintf(\\\"Hello, world!\\\\n\\\");\\n}\\n\"\n  },\n  {\n    \"foo\": \"blue\",\n    \"bar\": \"arrr\",\n    \"baz\": \"jazz\"\n  },\n  [\n    {\n      \"Mark McGwire\": 65\n    },\n    {\n      \"Sammy Sosa\": 63\n    },\n    {\n      \"Ken Griffy\": 58\n    }\n  ],\n  {\n    \"1\": 2,\n    \"3\": 4\n  },\n  {\n    \"hr\": [\"Mark McGwire\", \"Sammy Sosa\"],\n    \"rbi\": [\"Sammy Sosa\", \"Ken Griffey\"]\n  },\n  \"k:#foo &a !t s\",\n  {\n    \"block sequence\": [\n      \"one\",\n      {\n        \"two\": \"three\"\n      }\n    ]\n  },\n  {\n    \"First occurrence\": \"Value\",\n    \"Second occurrence\": \"Value\"\n  },\n  {\n    \"a true\": \"null d\",\n    \"e 42\": null\n  },\n  {\n    \"foo\": \"bar\"\n  },\n  [\"foo\", \"bar\", 42],\n  \"trimmed\\n\\n\\nas space\",\n  \"scalar\",\n  {\n    \"strip\": \"\",\n    \"clip\": \"\",\n    \"keep\": \"\\n\"\n  },\n  {\n    \"foo\": {\n      \"bar\": 1\n    },\n    \"baz\": 2\n  },\n  {\n    \"a\": 47,\n    \"c\": \"d\"\n  },\n  {\n    \"implicit block key\": [\n      {\n        \"implicit flow key\": \"value\"\n      }\n    ]\n  },\n  [\"a\", \"b\", \"c\", \"c\", \"\"],\n  [\n    [\"a\", \"b\", \"c\"],\n    {\n      \"a\": \"b\",\n      \"c\": \"d\",\n      \"e\": \"f\"\n    },\n    []\n  ],\n  {\n    \"implicit block key\": [\n      {\n        \"implicit flow key\": \"value\"\n      }\n    ]\n  },\n  {\n    \"a\": \"ab\\n\\ncd\\nef\\n\"\n  },\n  {\n    \"literal\": \"value\\n\",\n    \"folded\": \"value\\n\"\n  },\n  {\n    \"a\": [\n      \"b\",\n      \"c\",\n      {\n        \"d\": [\"e\", \"f\"]\n      }\n    ]\n  },\n  \"literal\\n\\ttext\\n\",\n  \"foo \\n\\n\\t bar\\n\\nbaz\\n\",\n  [\n    {\n      \"a\": \"b\"\n    }\n  ],\n  \"ab\",\n  [\"plain\", \"double quoted\", \"single quoted\", \"block\\n\", \"plain again\"],\n  {\n    \"a\": \" \",\n    \"b\": \" \",\n    \"c\": \" \",\n    \"d\": \" \",\n    \"e\": \"\\n\",\n    \"f\": \"\\n\",\n    \"g\": \"\\n\\n\",\n    \"h\": \"\\n\\n\"\n  },\n  {\n    \"key\": \"value with\\ntabs\"\n  },\n  {\n    \"\": null\n  },\n  [\n    {\n      \"single line\": \"value\"\n    },\n    {\n      \"multi line\": \"value\"\n    }\n  ],\n  \"folded to a space,\\nto a line feed, or \\t \\tnon-content\",\n  [\"literal\\n\", \" folded\\n\", \"keep\\n\\n\", \" strip\"],\n  {\n    \"key\": \"value\"\n  },\n  {\n    \"american\": [\"Boston Red Sox\", \"Detroit Tigers\", \"New York Yankees\"],\n    \"national\": [\"New York Mets\", \"Chicago Cubs\", \"Atlanta Braves\"]\n  },\n  \" 1st non-empty\\n2nd non-empty 3rd non-empty \",\n  {},\n  [\n    [\"a\", \"b\"],\n    {\n      \"a\": \"b\"\n    },\n    \"a\",\n    \"b\",\n    \"c\"\n  ],\n  \"folded to a space,\\nto a line feed, or \\t \\tnon-content\",\n  [\n    {\n      \"foo\": \"bar\"\n    }\n  ],\n  [\"detected\\n\", \"\\n\\n# detected\\n\", \" explicit\\n\", \"\\t\\ndetected\\n\"],\n  {\n    \"top1\": [\n      \"item1\",\n      {\n        \"key2\": \"value2\"\n      },\n      \"item3\"\n    ],\n    \"top2\": \"value2\"\n  },\n  {\n    \"foo\": [42],\n    \"bar\": [44]\n  },\n  {\n    \"23\": \"d\",\n    \"a\": 4.2\n  },\n  \"Document\",\n  {\n    \"Date\": \"2001-11-23 15:03:17 -5\",\n    \"User\": \"ed\",\n    \"Fatal\": \"Unknown variable \\\"bar\\\"\",\n    \"Stack\": [\n      {\n        \"file\": \"TopClass.py\",\n        \"line\": 23,\n        \"code\": \"x = MoreObject(\\\"345\\\\n\\\")\\n\"\n      },\n      { \"file\": \"MoreClass.py\", \"line\": 58, \"code\": \"foo = bar\" }\n    ]\n  },\n  {\n    \"plain key\": \"in-line value\",\n    \"\": null,\n    \"quoted key\": [\"entry\"]\n  },\n  [\"12\", 12, \"12\"],\n  {\n    \"aaa\": \"bbb\"\n  },\n  [\":,\"],\n  {\n    \"sequence\": [\"one\", \"two\"],\n    \"mapping\": {\n      \"sky\": \"blue\",\n      \"sea\": \"green\"\n    }\n  },\n  {\n    \"seq\": [\"a\", \"b\"]\n  },\n  \"here's to \\\"quotes\\\"\",\n  {\n    \"hr\": 65,\n    \"avg\": 0.278,\n    \"rbi\": 147\n  },\n  \"\\n\\nliteral\\n \\n\\ntext\\n\",\n  \" 1st non-empty\\n2nd non-empty 3rd non-empty \",\n  \"literal\\n\\ttext\\n\",\n  {\n    \"block mapping\": {\n      \"key\": \"value\"\n    }\n  },\n  \" foo\\nbar\\nbaz \",\n  \"ab cd\\nef\\n\\ngh\\n\",\n  \"foo\",\n  {\n    \"top1\": {\n      \"key1\": \"one\"\n    },\n    \"top2\": {\n      \"key2\": \"two\"\n    },\n    \"top3\": {\n      \"key3\": \"three\"\n    },\n    \"top4\": {\n      \"key4\": \"four\"\n    },\n    \"top5\": {\n      \"key5\": \"five\"\n    },\n    \"top6\": \"six\",\n    \"top7\": \"seven\"\n  },\n  [\n    {\n      \"url\": \"http://example.org\"\n    }\n  ],\n  {\n    \"sequence\": [\"one\", \"two\"],\n    \"mapping\": {\n      \"sky\": \"blue\",\n      \"sea\": \"green\"\n    }\n  },\n  {\n    \"invoice\": 34843,\n    \"date\": \"2001-01-23\",\n    \"bill-to\": {\n      \"given\": \"Chris\",\n      \"family\": \"Dumars\",\n      \"address\": {\n        \"lines\": \"458 Walkman Dr.\\nSuite #292\\n\",\n        \"city\": \"Royal Oak\",\n        \"state\": \"MI\",\n        \"postal\": 48046\n      }\n    },\n    \"ship-to\": {\n      \"given\": \"Chris\",\n      \"family\": \"Dumars\",\n      \"address\": {\n        \"lines\": \"458 Walkman Dr.\\nSuite #292\\n\",\n        \"city\": \"Royal Oak\",\n        \"state\": \"MI\",\n        \"postal\": 48046\n      }\n    },\n    \"product\": [\n      {\n        \"sku\": \"BL394D\",\n        \"quantity\": 4,\n        \"description\": \"Basketball\",\n        \"price\": 450\n      },\n      {\n        \"sku\": \"BL4438H\",\n        \"quantity\": 1,\n        \"description\": \"Super Hoop\",\n        \"price\": 2392\n      }\n    ],\n    \"tax\": 251.42,\n    \"total\": 4443.52,\n    \"comments\": \"Late afternoon is best. Backup contact is Nancy Billsmer @ 338-4338.\"\n  },\n  [\"a\", \"b\", \"a\", \"b\"],\n  [\n    null,\n    \"block node\\n\",\n    [\"one\", \"two\"],\n    {\n      \"one\": \"two\"\n    }\n  ],\n  {\n    \"a\": \"scalar a\",\n    \"b\": \"scalar a\"\n  },\n  {\n    \"foo\": \"\",\n    \"\": \"bar\"\n  },\n  {\n    \"key\": \"value\"\n  },\n  \"scalar %YAML 1.2\",\n  {\n    \"Folding\": \"Empty line\\nas a line feed\",\n    \"Chomping\": \"Clipped empty lines\\n\"\n  },\n  {\n    \"key\": \"value\"\n  },\n  [\n    [\"name\", \"hr\", \"avg\"],\n    [\"Mark McGwire\", 65, 0.278],\n    [\"Sammy Sosa\", 63, 0.288]\n  ],\n  {\n    \"literal\": \"value\\n\",\n    \"folded\": \"value\\n\"\n  },\n  {\n    \"Mark McGwire\": {\n      \"hr\": 65,\n      \"avg\": 0.278\n    },\n    \"Sammy Sosa\": {\n      \"hr\": 63,\n      \"avg\": 0.288\n    }\n  },\n  {\n    \"a\": \"b\",\n    \"c\": \"d\"\n  },\n  {\n    \"key\": [[[\"value\"]]]\n  },\n  {\n    \"a\": 1,\n    \"b\": null,\n    \"c\": 3\n  },\n  {\n    \"event\": \"outgoing HTTP response\",\n    \"timestamp\": \"2021-01-21T18:44:57.116Z\",\n    \"remoteAddress\": \"127.0.0.1\",\n    \"deviceId\": \"202BC1-BM632w-000000\",\n    \"connection\": \"2021-01-21T18:44:57.105Z\",\n    \"statusCode\": 200,\n    \"headers\": {\n      \"content-length\": 528,\n      \"server\": \"GenieACS/1.2.3+20210121000910\",\n      \"soapserver\": \"GenieACS/1.2.3+20210121000910\",\n      \"content-type\": \"text/xml; charset=\\\"utf-8\\\"\",\n      \"set-cookie\": \"session=ea31d97ccf6832fb\"\n    },\n    \"body\": \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\"?>\\n<soap-env:Envelope xmlns:soap-enc=\\\"http://schemas.xmlsoap.org/soap/encoding/\\\" xmlns:soap-env=\\\"http://schemas.xmlsoap.org/soap/envelope/\\\" xmlns:xsd=\\\"http://www.w3.org/2001/XMLSchema\\\" xmlns:xsi=\\\"http://www.w3.org/2001/XMLSchema-instance\\\" xmlns:cwmp=\\\"urn:dslforum-org:cwmp-1-0\\\"><soap-env:Header><cwmp:ID soap-env:mustUnderstand=\\\"1\\\">wdsx50vq</cwmp:ID></soap-env:Header><soap-env:Body><cwmp:InformResponse><MaxEnvelopes>1</MaxEnvelopes></cwmp:InformResponse></soap-env:Body></soap-env:Envelope>\"\n  }\n]\n"
  },
  {
    "path": "test/yaml.ts",
    "content": "import test from \"node:test\";\nimport assert from \"node:assert\";\nimport * as yaml from \"yaml\";\nimport { stringify } from \"../lib/common/yaml.ts\";\n\nimport testCases from \"./yaml-tests.json\";\n\nvoid test(\"stringify\", () => {\n  for (const testCase of testCases) {\n    let str = stringify(testCase);\n    if (str.startsWith(\">2\")) str = \">3\" + str.slice(2);\n    if (str.startsWith(\"|2\")) str = \"|3\" + str.slice(2);\n    assert.deepStrictEqual(\n      yaml.parse(yaml.stringify(testCase)),\n      yaml.parse(str),\n    );\n  }\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\"ES2022\", \"dom\"],\n    \"module\": \"Preserve\",\n    \"moduleResolution\": \"bundler\",\n    \"target\": \"ES2022\",\n    \"resolveJsonModule\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"alwaysStrict\": true,\n    \"noImplicitReturns\": true,\n    \"strictFunctionTypes\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"allowImportingTsExtensions\": true,\n    \"skipLibCheck\": true,\n    \"jsx\": \"react\",\n    \"jsxFactory\": \"m\"\n  },\n  \"include\": [\n    \"./bin/*.ts\",\n    \"./lib/**/*\",\n    \"./ui/**/*\",\n    \"./test/**/*\",\n    \"./build/**/*\"\n  ]\n}\n"
  },
  {
    "path": "ui/app.ts",
    "content": "import m, { RouteResolver } from \"mithril\";\nimport layout from \"./layout.tsx\";\nimport * as store from \"./store.ts\";\nimport { invalidate } from \"./reactive-store.ts\";\nimport * as wizardPage from \"./wizard-page.ts\";\nimport * as loginPage from \"./login-page.tsx\";\nimport * as overviewPage from \"./overview-page.ts\";\nimport * as devicesPage from \"./devices-page.ts\";\nimport * as devicePage from \"./device-page.ts\";\nimport * as errorPage from \"./error-page.ts\";\nimport * as faultsPage from \"./faults-page.ts\";\nimport * as presetsPage from \"./presets-page.ts\";\nimport * as provisionsPage from \"./provisions-page.ts\";\nimport * as virtualParametersPage from \"./virtual-parameters-page.ts\";\nimport * as filesPage from \"./files-page.ts\";\nimport * as configPage from \"./config-page.ts\";\nimport * as permissionsPage from \"./permissions-page.ts\";\nimport * as usersPage from \"./users-page.ts\";\nimport Authorizer from \"../lib//common/authorizer.ts\";\nimport { contextifyComponent } from \"./components.ts\";\nimport { PermissionSet, UiConfig } from \"../lib/types.ts\";\nimport drawerComponent from \"./drawer-component.ts\";\nimport { render as renderOverlay } from \"./overlay.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport * as viewsPage from \"./views-page.ts\";\n\nexport { ViewNode } from \"./views.ts\";\nexport { Signal } from \"./signals.ts\";\n\ndeclare global {\n  interface Window {\n    authorizer: Authorizer;\n    permissionSets: {\n      [resource: string]: { access: number; validate: string; filter: string };\n    }[][];\n    username: string;\n    clientConfig: UiConfig;\n    configSnapshot: string;\n    genieacsVersion: string;\n    clockSkew: number;\n  }\n}\n\nconst permissionSets: PermissionSet[] = window.permissionSets.map((p) =>\n  p.map((s) =>\n    Object.fromEntries(\n      Object.entries(s).map(([resource, { access, validate, filter }]) => [\n        resource,\n        {\n          access,\n          validate: Expression.parse(validate),\n          filter: Expression.parse(filter),\n        },\n      ]),\n    ),\n  ),\n);\n\nwindow.authorizer = new Authorizer(permissionSets);\n\nlet state;\n\nfunction pagify(pageName, page): RouteResolver {\n  const component: RouteResolver = {\n    render: () => {\n      const lastRenderTimestamp = Date.now();\n      let p;\n      if (state?.error) p = m(errorPage.component, state);\n      else p = m(contextifyComponent(page.component), state);\n      const attrs = {\n        page: pageName,\n        oncreate: () => {\n          store.fulfill(lastRenderTimestamp);\n        },\n        onupdate: () => {\n          store.fulfill(lastRenderTimestamp);\n        },\n      };\n      return m(layout, attrs, p);\n    },\n    onmatch: null,\n  };\n\n  component.onmatch = (args, requestedPath) => {\n    store.setTimestamp(Date.now());\n    invalidate(Date.now());\n    if (!page.init) {\n      state = null;\n      return null;\n    }\n\n    return new Promise<void>((resolve) => {\n      page\n        .init(args)\n        .then((st) => {\n          if (!st) return void m.route.set(\"/\");\n          state = st;\n          resolve();\n        })\n        .catch((err) => {\n          if (!window.username && err.message.indexOf(\"authorized\") >= 0)\n            m.route.set(\"/login\", { continue: requestedPath });\n          state = { error: err.message };\n          resolve();\n        });\n    });\n  };\n\n  return component;\n}\n\nm.route(document.body, \"/overview\", {\n  \"/wizard\": pagify(\"wizard\", wizardPage),\n  \"/overview\": pagify(\"overview\", overviewPage),\n  \"/devices\": pagify(\"devices\", devicesPage),\n  \"/devices/:id\": pagify(\"devices\", devicePage),\n  \"/faults\": pagify(\"faults\", faultsPage),\n  \"/presets\": pagify(\"presets\", presetsPage),\n  \"/provisions\": pagify(\"provisions\", provisionsPage),\n  \"/virtualParameters\": pagify(\"virtualParameters\", virtualParametersPage),\n  \"/files\": pagify(\"files\", filesPage),\n  \"/config\": pagify(\"config\", configPage),\n  \"/users\": pagify(\"users\", usersPage),\n  \"/permissions\": pagify(\"permissions\", permissionsPage),\n  \"/views\": pagify(\"views\", viewsPage),\n  \"/login\": {\n    render: () => [m(loginPage.component), renderOverlay(), m(drawerComponent)],\n  },\n});\n"
  },
  {
    "path": "ui/autocomplete-compnent.ts",
    "content": "type AutocompleteCallback = (\n  value: string,\n  callback: (suggestions: { value: string; tip?: string }[]) => void,\n) => void;\n\nexport default class Autocomplete {\n  declare private callback: AutocompleteCallback;\n  declare private element: HTMLInputElement;\n  declare private hideTimeout: NodeJS.Timeout;\n  declare private visible: boolean;\n  declare private default: string;\n  declare private selection: number;\n  declare private container: HTMLElement;\n\n  public constructor(callback: AutocompleteCallback) {\n    this.callback = callback;\n    this.element = null;\n    this.hideTimeout = null;\n    this.visible = false;\n    this.default = null;\n    this.selection = null;\n\n    this.container = document.createElement(\"div\");\n    this.container.style.position = \"absolute\";\n    this.container.style.display = \"block\";\n    this.container.style.opacity = \"0\";\n    this.container.className =\n      \"absolute py-1 mt-2 rounded-md shadow-lg bg-white\";\n  }\n\n  public attach(el: HTMLInputElement): void {\n    el.setAttribute(\"autocomplete\", \"off\");\n\n    el.addEventListener(\"focus\", () => {\n      this.element = el;\n      this.update();\n      this.reposition();\n    });\n\n    el.addEventListener(\"blur\", () => {\n      if (this.element !== el) return;\n      if (!this.visible) return;\n      this.hide();\n    });\n\n    el.addEventListener(\"keydown\", (e) => {\n      if (this.element !== el) return;\n      if (e.key === \"Escape\") {\n        if (this.visible) this.hide();\n      } else if (e.key === \"Enter\") {\n        if (this.default != null) {\n          el.value = this.default;\n          e.stopImmediatePropagation();\n          el.dispatchEvent(new InputEvent(\"input\"));\n          this.update();\n        }\n      } else if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        if (this.selection == null) this.selection = 0;\n        else ++this.selection;\n        this.update();\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        --this.selection;\n        this.update();\n      }\n    });\n\n    el.addEventListener(\"input\", () => {\n      if (this.element !== el) return;\n      this.selection = null;\n      this.update();\n    });\n  }\n\n  public reposition(): void {\n    if (!this.element) return;\n    const domRect = this.element.getBoundingClientRect();\n    if (!domRect.width) {\n      // Element has been removed\n      if (this.visible) this.hide();\n      return;\n    }\n    this.container.style.left = `${domRect.left + window.scrollX}px`;\n    this.container.style.width = `${domRect.width}px`;\n    this.container.style.top = `${domRect.bottom + window.scrollY}px`;\n  }\n\n  private hide(): void {\n    this.container.style.opacity = \"0\";\n    this.visible = false;\n    this.default = null;\n    this.selection = null;\n    clearTimeout(this.hideTimeout);\n    this.hideTimeout = setTimeout(() => {\n      this.hideTimeout = null;\n      while (this.container.firstChild)\n        this.container.removeChild(this.container.firstChild);\n      document.body.removeChild(this.container);\n    }, 500);\n  }\n\n  private update(): void {\n    const el = this.element;\n\n    this.callback(el.value, (suggestions) => {\n      if (this.element !== el) return;\n      this.default = null;\n\n      if (!suggestions.length) {\n        if (this.visible) this.hide();\n\n        return;\n      }\n\n      while (this.container.firstChild)\n        this.container.removeChild(this.container.firstChild);\n\n      if (!this.visible) {\n        if (!this.hideTimeout) {\n          document.body.appendChild(this.container);\n          // Force style recalc so the initial opacity is resolved before\n          // setting it to \"1\", allowing the CSS transition to play.\n          void window.getComputedStyle(this.container).opacity;\n        } else {\n          clearTimeout(this.hideTimeout);\n          this.hideTimeout = null;\n        }\n        this.container.style.opacity = \"1\";\n        this.visible = true;\n      }\n\n      if (this.selection != null) {\n        this.selection =\n          ((this.selection % suggestions.length) + suggestions.length) %\n          suggestions.length;\n        this.default = suggestions[this.selection].value;\n      } else {\n        this.default = suggestions[0].value;\n      }\n\n      let selectedElement;\n      for (const [idx, suggestion] of suggestions.entries()) {\n        const e = document.createElement(\"div\");\n        if (suggestion.tip) e.title = suggestion.tip;\n        e.className =\n          \"text-stone-700 block px-4 py-2 text-sm hover:bg-stone-100 hover:text-stone-900\";\n        if (idx === this.selection) {\n          e.classList.add(\"bg-stone-100\", \"text-stone-900\");\n          selectedElement = e;\n        }\n\n        const t = document.createTextNode(suggestion.value);\n        e.appendChild(t);\n        e.addEventListener(\"mousedown\", (ev) => {\n          ev.preventDefault();\n          el.value = suggestion.value;\n          el.dispatchEvent(new InputEvent(\"input\"));\n          if (this.element === el) this.update();\n        });\n        this.container.appendChild(e);\n      }\n\n      // Ensure selected element is in view\n      if (selectedElement) {\n        this.container.scrollTop = Math.min(\n          this.container.scrollTop,\n          selectedElement.offsetTop,\n        );\n\n        this.container.scrollTop = Math.max(\n          this.container.scrollTop,\n          selectedElement.offsetTop +\n            selectedElement.scrollHeight -\n            this.container.clientHeight,\n        );\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "ui/change-password-component.ts",
    "content": "import { VnodeDOM, ClosureComponent } from \"mithril\";\nimport * as notifications from \"./notifications.ts\";\nimport { changePassword } from \"./store.ts\";\nimport { m } from \"./components.ts\";\n\ninterface Attrs {\n  noAuth?: boolean;\n  username?: string;\n  onPasswordChange: () => void;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      const onPasswordChange = vnode.attrs.onPasswordChange;\n      const enforceAuth = !vnode.attrs.noAuth;\n      const username = vnode.attrs.username;\n\n      if (username) vnode.state[\"username\"] = username;\n\n      const form = [\n        m(\n          \"p\",\n          m(\n            \"label.block text-sm font-semibold text-stone-700 mt-2 mb-1\",\n            { for: \"username\" },\n            \"Username\",\n          ),\n          m(\n            \"input.shadow-sm focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md\",\n            {\n              name: \"username\",\n              type: \"text\",\n              value: vnode.state[\"username\"],\n              disabled: !!username,\n              oninput: (e) => {\n                vnode.state[\"username\"] = e.target.value;\n              },\n              oncreate: (_vnode) => {\n                (_vnode.dom as HTMLSelectElement).focus();\n              },\n            },\n          ),\n        ),\n      ];\n\n      let fields = {\n        newPassword: \"New password\",\n        confirmPassword: \"Confirm password\",\n      };\n      if (enforceAuth)\n        fields = Object.assign({ authPassword: \"Your password\" }, fields);\n\n      for (const [f, l] of Object.entries(fields)) {\n        form.push(\n          m(\n            \"p\",\n            m(\n              \"label.block text-sm font-semibold text-stone-700 mt-2 mb-1\",\n              { for: f },\n              l,\n            ),\n            m(\n              \"input.shadow-sm focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md\",\n              {\n                name: f,\n                type: \"password\",\n                value: vnode.state[f],\n                oninput: (e) => {\n                  vnode.state[f] = e.target.value;\n                },\n              },\n            ),\n          ),\n        );\n      }\n\n      const submit = m(\n        \"button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\",\n        {\n          type: \"submit\",\n        },\n        \"Change password\",\n      ) as VnodeDOM;\n\n      form.push(m(\"div.flex justify-end mt-5\", submit));\n\n      const children = [\n        m(\"h2.text-lg leading-6 font-medium text-stone-900\", \"Change password\"),\n        m(\n          \"form\",\n          {\n            onsubmit: (e) => {\n              e.redraw = false;\n              e.preventDefault();\n              if (\n                !vnode.state[\"username\"] ||\n                !vnode.state[\"newPassword\"] ||\n                (enforceAuth && !vnode.state[\"authPassword\"])\n              ) {\n                notifications.push(\"error\", \"Please fill all fields\");\n              } else if (\n                vnode.state[\"newPassword\"] !== vnode.state[\"confirmPassword\"]\n              ) {\n                notifications.push(\n                  \"error\",\n                  \"Password confirm doesn't match new password\",\n                );\n              } else {\n                (submit.dom as HTMLFormElement).disabled = true;\n                changePassword(\n                  vnode.state[\"username\"],\n                  vnode.state[\"newPassword\"],\n                  vnode.state[\"authPassword\"],\n                )\n                  .then(() => {\n                    notifications.push(\n                      \"success\",\n                      \"Password updated successfully\",\n                    );\n                    if (onPasswordChange) onPasswordChange();\n                    (submit.dom as HTMLFormElement).disabled = false;\n                  })\n                  .catch((err) => {\n                    notifications.push(\"error\", err.message);\n                    (submit.dom as HTMLFormElement).disabled = false;\n                  });\n              }\n            },\n          },\n          form,\n        ),\n      ];\n\n      return m(\"div.put-form\", children);\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/code-editor-component.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { codeMirror } from \"./dynamic-loader.ts\";\n\ninterface Attrs {\n  id: string;\n  value: string;\n  mode: string;\n  readOnly?: boolean;\n  focus?: boolean;\n  onSubmit?: (dom: Element) => void;\n  onChange?: (value: string) => void;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      return m(\"div.font-mono text-sm\", {\n        oncreate: (_vnode) => {\n          const theme = codeMirror.EditorView.theme({\n            \"&.cm-editor\": {\n              display: \"block\",\n              width: \"50rem\",\n              height: \"30rem\",\n              \"max-width\": \"100%\",\n              \"border-radius\": \"0.375rem\",\n              \"border-width\": \"1px\",\n              \"border-color\": \"var(--color-stone-300)\",\n              \"box-shadow\":\n                \"var(--tw-ring-shadow, 0 0 #0000), 0 1px 2px 0 rgb(0 0 0 / 0.05)\",\n              overflow: \"hidden\",\n              \"& > .cm-scroller\": {\n                \"font-family\": \"inherit\",\n                \"line-height\": \"inherit\",\n              },\n            },\n            \"&.cm-editor.cm-focused\": {\n              outline: \"none\",\n              \"border-color\": \"var(--color-cyan-500)\",\n              \"--tw-ring-shadow\": \"0 0 0 1px var(--color-cyan-500)\",\n            },\n          });\n\n          const extensions = [\n            theme,\n            codeMirror.lineNumbers(),\n            codeMirror.history(),\n            codeMirror.syntaxHighlighting(codeMirror.defaultHighlightStyle),\n            codeMirror.keymap.of([\n              ...codeMirror.defaultKeymap,\n              ...codeMirror.historyKeymap,\n            ]),\n            codeMirror.EditorState.readOnly.of(!!vnode.attrs.readOnly),\n            codeMirror.EditorView.updateListener.of((update) => {\n              if (update.docChanged && vnode.attrs.onChange)\n                vnode.attrs.onChange(update.state.doc.toString());\n            }),\n          ];\n\n          if (vnode.attrs.mode === \"javascript\")\n            extensions.push(codeMirror.javascript());\n          else if (vnode.attrs.mode === \"jsx\")\n            extensions.push(codeMirror.javascript({ jsx: true }));\n          else if (vnode.attrs.mode === \"yaml\")\n            extensions.push(codeMirror.yaml());\n\n          const editor = new codeMirror.EditorView({\n            state: codeMirror.EditorState.create({\n              doc: vnode.attrs.value,\n              extensions,\n            }),\n            parent: _vnode.dom as HTMLTextAreaElement,\n          });\n\n          if (vnode.attrs.focus) editor.focus();\n        },\n      });\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/codemirror-loader.ts",
    "content": "export { EditorView, keymap, lineNumbers } from \"@codemirror/view\";\nexport { EditorState } from \"@codemirror/state\";\nexport { history, historyKeymap, defaultKeymap } from \"@codemirror/commands\";\nexport {\n  syntaxHighlighting,\n  defaultHighlightStyle,\n} from \"@codemirror/language\";\nexport { javascript } from \"@codemirror/lang-javascript\";\nexport { yaml } from \"@codemirror/lang-yaml\";\n"
  },
  {
    "path": "ui/components/all-parameters.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as taskQueue from \"../task-queue.ts\";\nimport memoize from \"../../lib/common/memoize.ts\";\nimport { icon } from \"../tailwind-utility-components.ts\";\nimport { QueryResponse, evaluateExpression } from \"../store.ts\";\nimport debounce from \"../../lib/common/debounce.ts\";\nimport Expression, { Value } from \"../../lib/common/expression.ts\";\nimport { FlatDevice } from \"../../lib/ui/db.ts\";\nimport Path from \"../../lib/common/path.ts\";\n\nfunction escapeRegExp(str): string {\n  return str.replace(/[-[\\]/{}()*+?.\\\\^$|]/g, \"\\\\$&\");\n}\n\ninterface Parameter {\n  path: Expression.Parameter;\n  value?: Value;\n  writable?: boolean;\n  object?: boolean;\n}\n\nconst prepareParams = memoize((device: FlatDevice): Parameter[][] => {\n  const map = new Map<string, Parameter>();\n\n  for (const [k, v] of Object.entries(device)) {\n    const [param, attr] = k.split(\":\");\n    let attrs = map.get(param);\n    if (!attrs)\n      map.set(\n        param,\n        (attrs = { path: new Expression.Parameter(Path.parse(param)) }),\n      );\n    attrs[attr || \"value\"] = v;\n  }\n\n  const res: Parameter[][] = [];\n  for (const [key, attrs] of map) {\n    let count = 0;\n    for (\n      let i = key.lastIndexOf(\".\", key.length - 2);\n      i >= 0;\n      i = key.lastIndexOf(\".\", i - 1)\n    )\n      ++count;\n    while (res.length <= count) res.push([]);\n    res[count].push(attrs);\n  }\n  return res;\n});\n\ninterface Attrs {\n  device: FlatDevice;\n  limit: Expression;\n  deviceQuery: QueryResponse;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  let queryString: string;\n  const formQueryString = debounce((args: string[]) => {\n    queryString = args[args.length - 1];\n    m.redraw();\n  }, 500);\n\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs.device;\n      const allParams = prepareParams(device);\n\n      let limit = 100;\n      if (vnode.attrs.limit) {\n        const l = evaluateExpression(vnode.attrs.limit, device);\n        if (typeof l.value === \"number\") limit = l.value;\n      }\n\n      const search = m(\n        \"input.appearance-none border-0 block w-full px-4 py-3 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 text-sm rounded-t-lg font-mono focus:ring-2\",\n        {\n          type: \"text\",\n          placeholder: \"Search parameters\",\n          oninput: (e) => {\n            formQueryString(e.target.value);\n            e.redraw = false;\n          },\n        },\n      );\n\n      const instanceRegex = /\\.[0-9]+$/;\n      let re;\n      if (queryString) {\n        const keywords = queryString.split(\" \").filter((s) => s);\n        if (keywords.length)\n          re = new RegExp(keywords.map((s) => escapeRegExp(s)).join(\".*\"), \"i\");\n      }\n\n      const filteredParams: Parameter[] = [];\n      let count = 0;\n      for (const keys of allParams) {\n        let c = 0;\n        for (const k of keys) {\n          const str = k.value ? `${k.path.toString()} ${k.value}` : k;\n          if (re && !re.test(str)) continue;\n          ++c;\n          if (count < limit) filteredParams.push(k);\n        }\n        count += c;\n      }\n\n      filteredParams.sort((a, b) => {\n        if (a.path < b.path) return -1;\n        if (a.path > b.path) return 1;\n        return 0;\n      });\n\n      const rows = filteredParams.map((p) => {\n        const val = [];\n        if (p.value) {\n          val.push(\n            m(\n              \"parameter\",\n              Object.assign({ device: device, parameter: p.path }),\n            ),\n          );\n        } else if (p.object && p.writable) {\n          if (instanceRegex.test(p.path.toString())) {\n            val.push(\n              m(\n                \"button\",\n                {\n                  title: \"Delete this instance\",\n                  onclick: () => {\n                    taskQueue.queueTask({\n                      name: \"deleteObject\",\n                      device: device[\"DeviceID.ID\"] as string,\n                      objectName: p.path.toString(),\n                    });\n                  },\n                },\n                m(icon, {\n                  name: \"delete-instance\",\n                  class:\n                    \"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\",\n                }),\n              ),\n            );\n          } else {\n            val.push(\n              m(\n                \"button\",\n                {\n                  title: \"Create a new instance\",\n                  onclick: () => {\n                    taskQueue.queueTask({\n                      name: \"addObject\",\n                      device: device[\"DeviceID.ID\"] as string,\n                      objectName: p.path.toString(),\n                    });\n                  },\n                },\n                m(icon, {\n                  name: \"add-instance\",\n                  class:\n                    \"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\",\n                }),\n              ),\n            );\n          }\n        }\n\n        val.push(\n          m(\n            \"button\",\n            {\n              title: \"Refresh tree\",\n              onclick: () => {\n                taskQueue.queueTask({\n                  name: \"getParameterValues\",\n                  device: device[\"DeviceID.ID\"] as string,\n                  parameterNames: [p.path.toString()],\n                });\n              },\n            },\n            m(icon, {\n              name: \"refresh\",\n              class: \"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\",\n            }),\n          ),\n        );\n\n        return m(\n          \"tr\",\n          m(\n            \"td.pl-4 pr-2 py-2 truncate\",\n            m(\"long-text\", { text: p.path.toString() }),\n          ),\n          m(\"td.pr-4 py-2 text-right flex justify-end\", val),\n        );\n      });\n\n      return m(\n        \"loading\",\n        { queries: [vnode.attrs.deviceQuery] },\n        m(\n          \".bg-white shadow-sm rounded-lg\",\n          search,\n          m(\n            \".overflow-hidden\",\n            m(\n              \".overflow-y-scroll h-96 shadow-inner\",\n              m(\n                \"table.w-full table-fixed font-mono text-xs text-stone-900\",\n                m(\"tbody.divide-y divide-stone-200\", rows),\n              ),\n            ),\n            m(\n              \"div.text-stone-700 px-4 py-3 flex justify-between items-end\",\n              m(\n                \"span.text-xs\",\n                \"Displaying \",\n                m(\"span.font-medium\", \"\" + filteredParams.length),\n                \" out of \",\n                m(\"span.font-medium\", \"\" + count),\n                \" parameters\",\n              ),\n              m(\n                \"a.text-cyan-700 hover:text-cyan-900 text-sm font-medium\",\n                {\n                  href: `api/devices/${encodeURIComponent(\n                    device[\"DeviceID.ID\"],\n                  )}.csv`,\n                  download: \"\",\n                },\n                \"Download\",\n              ),\n            ),\n          ),\n        ),\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/container.ts",
    "content": "import { Attributes, ClosureComponent } from \"mithril\";\nimport memoize from \"../../lib/common/memoize.ts\";\nimport Expression from \"../../lib/common/expression.ts\";\nimport { m } from \"../components.ts\";\nimport { evaluateExpression, getTimestamp } from \"../store.ts\";\nimport { FlatDevice } from \"../../lib/ui/db.ts\";\n\nconst evaluateAttributes = memoize(\n  (\n    attrs: Record<string, Expression>,\n    obj: Record<string, unknown>,\n    now: number, // eslint-disable-line @typescript-eslint/no-unused-vars\n  ): Attributes => {\n    const res: Attributes = {};\n    for (const [k, v] of Object.entries(attrs)) {\n      const vv = v.evaluate((e) => {\n        if (e instanceof Expression.Literal) return e;\n        else if (e instanceof Expression.FunctionCall) {\n          if (e.name === \"ENCODEURICOMPONENT\") {\n            const a = evaluateExpression(e.args[0], obj);\n            if (a instanceof Expression.Literal) {\n              if (a.value == null) return new Expression.Literal(null);\n              return new Expression.Literal(encodeURIComponent(a.value));\n            }\n          }\n        }\n        return e;\n      });\n      res[k] = evaluateExpression(vv, obj);\n    }\n    return res;\n  },\n);\n\ninterface Attrs {\n  device: FlatDevice;\n  filter: Expression;\n  components: unknown;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs.device;\n      if (\"filter\" in vnode.attrs) {\n        if (!evaluateExpression(vnode.attrs.filter, device || {}).value)\n          return null;\n      }\n\n      const children = Object.values(vnode.attrs.components).map((c) => {\n        if (c instanceof Expression)\n          c = evaluateExpression(c, device || {}).value;\n        if (typeof c !== \"object\") return `${c}`;\n        const type = evaluateExpression(c[\"type\"], device || {}).value;\n        if (!type) return null;\n        return m(type as string, c);\n      });\n\n      let el = vnode.attrs[\"element\"];\n\n      if (el == null) return children;\n\n      let attrs: Attributes;\n      if (el instanceof Expression) {\n        el = evaluateExpression(el, device || {}).value;\n      } else if (typeof el === \"object\") {\n        if (el[\"attributes\"] != null) {\n          attrs = evaluateAttributes(\n            el[\"attributes\"],\n            device || {},\n            getTimestamp(),\n          );\n        }\n\n        el = evaluateExpression(el[\"tag\"], device || {}).value;\n      }\n\n      return m(el, attrs, children);\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/device-actions.ts",
    "content": "import { ClosureComponent, Component } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as taskQueue from \"../task-queue.ts\";\nimport * as notifications from \"../notifications.ts\";\nimport * as store from \"../store.ts\";\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs[\"device\"];\n\n      const buttons = [];\n\n      buttons.push(\n        m(\n          \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n          {\n            title: \"Reboot device\",\n            onclick: () => {\n              taskQueue.queueTask({\n                name: \"reboot\",\n                device: device[\"DeviceID.ID\"],\n              });\n            },\n          },\n          \"Reboot\",\n        ),\n      );\n\n      buttons.push(\n        m(\n          \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n          {\n            title: \"Factory reset device\",\n            onclick: () => {\n              taskQueue.queueTask({\n                name: \"factoryReset\",\n                device: device[\"DeviceID.ID\"],\n              });\n            },\n          },\n          \"Reset\",\n        ),\n      );\n\n      buttons.push(\n        m(\n          \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n          {\n            title: \"Push a firmware or a config file\",\n            onclick: () => {\n              taskQueue.stageDownload({\n                name: \"download\",\n                devices: [device[\"DeviceID.ID\"]],\n              });\n            },\n          },\n          \"Push file\",\n        ),\n      );\n\n      buttons.push(\n        m(\n          \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n          {\n            title: \"Delete device\",\n            onclick: () => {\n              if (!confirm(\"Deleting this device. Are you sure?\")) return;\n              const deviceId = device[\"DeviceID.ID\"];\n\n              store\n                .deleteResource(\"devices\", deviceId)\n                .then(() => {\n                  notifications.push(\"success\", `${deviceId}: Device deleted`);\n                  m.route.set(\"/devices\");\n                })\n                .catch((err) => {\n                  notifications.push(\"error\", err.message);\n                });\n            },\n          },\n          \"Delete\",\n        ),\n      );\n\n      return m(\"div.flex gap-3 mt-4\", buttons);\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/device-faults.ts",
    "content": "import { ClosureComponent, Component } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as store from \"../store.ts\";\nimport { invalidate } from \"../reactive-store.ts\";\nimport * as notifications from \"../notifications.ts\";\nimport { stringify } from \"../../lib/common/yaml.ts\";\nimport Expression from \"../../lib/common/expression.ts\";\nimport Path from \"../../lib/common/path.ts\";\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs[\"device\"];\n      const deviceId = device[\"DeviceID.ID\"];\n      const p = new Expression.Parameter(Path.parse(\"_id\"));\n      const exp = Expression.and(\n        new Expression.Binary(\">\", p, new Expression.Literal(`${deviceId}:`)),\n        new Expression.Binary(\n          \"<\",\n          p,\n          new Expression.Literal(`${deviceId}:zzzz`),\n        ),\n      );\n      const faults = store.fetch(\"faults\", exp);\n\n      const headers = [\n        \"Channel\",\n        \"Code\",\n        \"Message\",\n        \"Detail\",\n        \"Retries\",\n        \"Timestamp\",\n        \"\",\n      ].map((l, i) => {\n        let padding: string;\n        if (i === 0) padding = \"pl-6 pr-3\";\n        else if (i === 6) padding = \"pl-3\";\n        else padding = \"px-3\";\n        return m(\n          \"th\",\n          {\n            scope: \"col\",\n            class:\n              \"py-3.5 text-left text-sm font-semibold text-stone-500 \" +\n              padding,\n          },\n          l,\n        );\n      });\n      const thead = m(\"thead.bg-stone-50\", m(\"tr\", headers));\n\n      const rows = [];\n      for (const f of faults.value) {\n        rows.push(\n          m(\n            \"tr\",\n            m(\n              \"td.whitespace-nowrap pl-6 pr-3 py-4 text-sm text-stone-900\",\n              f[\"channel\"],\n            ),\n            m(\n              \"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900\",\n              f[\"code\"],\n            ),\n            m(\n              \"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900\",\n              m(\"long-text\", { text: f[\"message\"], class: \"max-w-xs\" }),\n            ),\n            m(\n              \"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900\",\n              m(\"long-text\", {\n                text: stringify(f[\"detail\"]),\n                class: \"max-w-xs\",\n              }),\n            ),\n\n            m(\n              \"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900\",\n              f[\"retries\"],\n            ),\n            m(\n              \"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900\",\n              new Date(f[\"timestamp\"]).toLocaleString(),\n            ),\n            m(\n              \"td.whitespace-nowrap pl-3 pr-6 py-4 text-sm text-stone-900\",\n              m(\n                \"button\",\n                {\n                  class: \"text-cyan-700 hover:text-cyan-900 font-medium\",\n                  title: \"Delete fault\",\n                  onclick: (e) => {\n                    e.redraw = false;\n                    store\n                      .deleteResource(\"faults\", f[\"_id\"])\n                      .then(() => {\n                        notifications.push(\"success\", \"Fault deleted\");\n                        store.setTimestamp(Date.now());\n                        invalidate(Date.now());\n                        m.redraw();\n                      })\n                      .catch((err) => {\n                        notifications.push(\"error\", err.message);\n                        store.setTimestamp(Date.now());\n                        invalidate(Date.now());\n                      });\n                  },\n                },\n                \"Delete\",\n              ),\n            ),\n          ),\n        );\n      }\n\n      if (!rows.length) {\n        rows.push(\n          m(\n            \"tr\",\n            m(\n              \"td.bg-stripes text-sm font-medium text-center text-stone-500 p-4\",\n              { colspan: headers.length },\n              \"No faults\",\n            ),\n          ),\n        );\n      }\n\n      return m(\n        \"loading\",\n        { queries: [faults] },\n        m(\n          \"div.shadow-sm overflow-hidden rounded-lg w-max\",\n          m(\n            \"table.divide-y divide-stone-200\",\n            thead,\n            m(\"tbody.divide-y divide-stone-200 bg-white\", rows),\n          ),\n        ),\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/device-link.ts",
    "content": "import { ClosureComponent, Component } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport { evaluateExpression } from \"../store.ts\";\nimport Expression from \"../../lib/common/expression.ts\";\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      let deviceId;\n      const device = vnode.attrs[\"device\"];\n      if (device) deviceId = device[\"DeviceID.ID\"];\n\n      const children = Object.values(vnode.attrs[\"components\"]).map((c) => {\n        if (c instanceof Expression)\n          c = evaluateExpression(c, device ?? {}).value;\n        if (typeof c !== \"object\") return `${c}`;\n        const type = evaluateExpression(c[\"type\"], device ?? {}).value;\n        if (!type) return null;\n        const attrs = Object.assign({}, vnode.attrs, c);\n        return m(type as string, attrs);\n      });\n      if (deviceId) {\n        return m(\n          \"a.text-cyan-700 hover:text-cyan-900 font-medium\",\n          { href: `#!/devices/${encodeURIComponent(deviceId)}` },\n          children,\n        );\n      } else {\n        return children;\n      }\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/loading.ts",
    "content": "import { VnodeDOM, ClosureComponent, Component } from \"mithril\";\n\nconst component: ClosureComponent = (): Component => {\n  let overlay: HTMLElement;\n  let dom: Element;\n  let loading = false;\n\n  function apply(vnode: VnodeDOM): void {\n    if (!loading) {\n      if (overlay) overlay.parentElement.remove();\n      if (dom) dom.classList.remove(\"loading\");\n      overlay = null;\n      dom = null;\n      return;\n    }\n\n    if (dom && dom !== vnode.dom) dom.classList.remove(\"loading\");\n\n    dom = vnode.dom;\n    dom.classList.add(\"loading\");\n\n    if (!overlay) {\n      const wrapper = document.createElement(\"div\");\n      wrapper.style.position = \"relative\";\n      wrapper.style.pointerEvents = \"none\";\n      overlay = document.createElement(\"div\");\n      overlay.classList.add(\"loading-overlay\");\n      overlay.style.position = \"absolute\";\n      wrapper.appendChild(overlay);\n    }\n\n    const wrapper = overlay.parentElement;\n    if (wrapper.parentElement !== dom.parentElement)\n      dom.parentNode.appendChild(wrapper);\n\n    const wrapperRect = wrapper.getBoundingClientRect();\n    const domRect = dom.getBoundingClientRect();\n    overlay.style.width = `${dom.scrollWidth}px`;\n    overlay.style.height = `${dom.scrollHeight}px`;\n    overlay.style.left = `${domRect.left - wrapperRect.left}px`;\n    overlay.style.top = `${domRect.top - wrapperRect.top}px`;\n  }\n\n  return {\n    view: (vnode) => {\n      const queries = vnode.attrs[\"queries\"];\n      loading = queries.some((q) => q.fulfilling);\n      return vnode.children;\n    },\n    oncreate: apply,\n    onupdate: apply,\n    onremove: () => {\n      if (overlay) overlay.parentElement.remove();\n      if (dom) dom.classList.remove(\"loading\");\n      overlay = null;\n      dom = null;\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/overview-dot.ts",
    "content": "import { ClosureComponent, Component } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport { overview } from \"../config.ts\";\nimport { evaluateExpression } from \"../store.ts\";\n\nconst CHARTS = overview.charts;\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs[\"device\"];\n      const chartName = evaluateExpression(\n        vnode.attrs[\"chart\"],\n        device ?? {},\n      ).value;\n      const chart = CHARTS[chartName as string];\n      if (!chart) return null;\n      for (const slice of chart.slices) {\n        const filter = slice.filter;\n        if (evaluateExpression(filter, device ?? {}).value) {\n          const dot = m(\n            \"svg.inline\",\n            {\n              width: \"1em\",\n              height: \"1em\",\n              style: \"margin: 0 0.2em 0.2em\",\n              xmlns: \"http://www.w3.org/2000/svg\",\n              \"xmlns:xlink\": \"http://www.w3.org/1999/xlink\",\n            },\n            m(\"circle.stroke-stone-200 stroke-1\", {\n              cx: \"0.5em\",\n              cy: \"0.5em\",\n              r: \"0.4em\",\n              fill: slice.color,\n            }),\n          );\n          return m(\"span\", dot, slice.label);\n        }\n      }\n      return null;\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/parameter-list.ts",
    "content": "import { ClosureComponent, VnodeDOM } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport { QueryResponse, evaluateExpression } from \"../store.ts\";\nimport { FlatDevice } from \"../../lib/ui/db.ts\";\nimport Expression from \"../../lib/common/expression.ts\";\n\ninterface Attrs {\n  device: FlatDevice;\n  parameters: Record<\n    string,\n    { type?: Expression; label: Expression; parameter: Expression }\n  >;\n  deviceQuery: QueryResponse;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs.device;\n\n      const rows = Object.values(vnode.attrs.parameters).map((parameter) => {\n        let type = \"parameter\";\n        if (parameter.type) {\n          const t = evaluateExpression(parameter.type, device);\n          if (typeof t.value === \"string\") type = t.value;\n        }\n        const p = m.context(\n          {\n            device: device,\n            parameter: parameter.parameter,\n          },\n          (type as string) || \"parameter\",\n          parameter,\n        );\n\n        return m(\n          \"div.py-3 grid grid-cols-3 gap-4 px-6\",\n          {\n            oncreate: (vn) => {\n              (vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom\n                ? \"\"\n                : \"none\";\n            },\n            onupdate: (vn) => {\n              (vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom\n                ? \"\"\n                : \"none\";\n            },\n          },\n          m(\n            \"dt.text-sm font-medium text-stone-500\",\n            evaluateExpression(parameter[\"label\"], device).value,\n          ),\n          m(\"dd.text-sm text-stone-900 col-span-2\", p),\n        );\n      });\n\n      return m(\n        \"loading\",\n        { queries: [vnode.attrs.deviceQuery] },\n        m(\n          \"dl.bg-white shadow-sm overflow-hidden rounded-lg w-max py-1\",\n          { class: \"[&>*+*]:border-t [&>*+*]:border-stone-200\" },\n          rows,\n        ),\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/parameter-table.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as taskQueue from \"../task-queue.ts\";\nimport { QueryResponse, evaluateExpression } from \"../store.ts\";\nimport { icon } from \"../tailwind-utility-components.ts\";\nimport { FlatDevice } from \"../../lib/ui/db.ts\";\nimport Expression from \"../../lib/common/expression.ts\";\nimport Path from \"../../lib/common/path.ts\";\n\ninterface Attrs {\n  device: FlatDevice;\n  parameter: Expression;\n  label: Expression;\n  childParameters: Record<string, { label: Expression; parameter: Expression }>;\n  filter?: Expression;\n  deviceQuery: QueryResponse;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  let object: Path;\n  let parameters: { label: Expression; parameter: Expression }[];\n\n  return {\n    oninit: (vnode) => {\n      const obj = vnode.attrs.parameter;\n      if (!(obj instanceof Expression.Parameter))\n        throw new Error(\"Object must be a parameter path\");\n      object = obj.path;\n      parameters = Object.values(vnode.attrs.childParameters);\n    },\n    view: (vnode) => {\n      const device = vnode.attrs.device;\n      const instances: Set<string> = new Set();\n      const prefix = `${object.toString()}.`;\n      for (const p in device) {\n        if (!p.startsWith(prefix)) continue;\n        if (p.lastIndexOf(\":\") !== -1) continue;\n        const i = p.indexOf(\".\", prefix.length);\n        if (i === -1) instances.add(p);\n        else instances.add(p.slice(0, i));\n      }\n\n      const headers = parameters.map((p, i) => {\n        const padding = i ? \"px-3\" : \"pl-6 pr-3\";\n\n        return m(\n          \"th\",\n          {\n            scope: \"col\",\n            class:\n              \"py-3.5 text-left text-sm font-semibold text-stone-500 \" +\n              padding,\n          },\n          evaluateExpression(p.label, device).value,\n        );\n      });\n\n      headers.push(m(\"th.pl-3\", { scope: \"col\" }));\n\n      const thead = m(\"thead.bg-stone-50\", m(\"tr\", headers));\n\n      const rows = [];\n      for (const i of instances) {\n        let filter: Expression =\n          \"filter\" in vnode.attrs\n            ? vnode.attrs.filter\n            : new Expression.Literal(true);\n\n        const root = Path.parse(i);\n        filter = filter.evaluate((e) => {\n          if (e instanceof Expression.Parameter)\n            return new Expression.Parameter(root.concat(e.path));\n          return e;\n        });\n\n        if (!evaluateExpression(filter, device).value) continue;\n\n        const row = parameters.map((p, j) => {\n          const padding = j ? \"px-3\" : \"pl-6 pr-3\";\n\n          const param = p.parameter.evaluate((e) => {\n            if (e instanceof Expression.Parameter)\n              return new Expression.Parameter(root.concat(e.path));\n            return e;\n          });\n\n          let type = \"parameter\";\n          if (p[\"type\"] instanceof Expression)\n            type = evaluateExpression(p[\"type\"], device).value + \"\";\n\n          return m(\n            \"td\",\n            {\n              class: \"whitespace-nowrap py-4 text-sm text-stone-900 \" + padding,\n            },\n            m.context(\n              {\n                device: device,\n                parameter: param,\n              },\n              type,\n              Object.assign({}, p, {\n                device: device,\n                parameter: param,\n                label: null,\n              }),\n            ),\n          );\n        });\n\n        if (device[i + \":writable\"]) {\n          row.push(\n            m(\n              \"td\",\n              {\n                class:\n                  \"whitespace-nowrap pl-3 pr-6 py-4 text-sm text-stone-900\",\n              },\n              m(\n                \"button\",\n                {\n                  title: \"Delete this instance\",\n                  onclick: () => {\n                    taskQueue.queueTask({\n                      name: \"deleteObject\",\n                      device: device[\"DeviceID.ID\"] as string,\n                      objectName: i,\n                    });\n                  },\n                },\n                m(icon, {\n                  name: \"delete-instance\",\n                  class: \"inline h-4 w-4 text-cyan-700 hover:text-cyan-900\",\n                }),\n              ),\n            ),\n          );\n        } else {\n          row.push(m(\"td\"));\n        }\n        rows.push(m(\"tr\", row));\n      }\n\n      if (!rows.length) {\n        rows.push(\n          m(\n            \"tr\",\n            m(\n              \"td.bg-stripes text-sm font-medium text-center text-stone-500 p-4\",\n              { colspan: headers.length },\n              \"No instances\",\n            ),\n          ),\n        );\n      }\n\n      if (device[object.toString() + \":writable\"]) {\n        rows.push(\n          m(\n            \"tr\",\n            m(\"td\", { colspan: headers.length }),\n            m(\n              \"td\",\n              m(\n                \"button\",\n                {\n                  title: \"Create a new instance\",\n                  onclick: () => {\n                    taskQueue.queueTask({\n                      name: \"addObject\",\n                      device: device[\"DeviceID.ID\"] as string,\n                      objectName: object.toString(),\n                    });\n                  },\n                },\n                m(icon, {\n                  name: \"add-instance\",\n                  class:\n                    \"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\",\n                }),\n              ),\n            ),\n          ),\n        );\n      }\n\n      let label;\n\n      const l = evaluateExpression(vnode.attrs.label, device).value;\n      if (l != null) label = m(\"h2\", l);\n\n      return [\n        label,\n        m(\n          \"loading\",\n          { queries: [vnode.attrs.deviceQuery] },\n          m(\n            \"div.shadow-sm overflow-hidden rounded-lg w-max\",\n            m(\n              \"table.divide-y divide-stone-200\",\n              thead,\n              m(\"tbody.divide-y divide-stone-200 bg-white\", rows),\n            ),\n          ),\n        ),\n      ];\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/parameter.ts",
    "content": "import { ClosureComponent, Component, VnodeDOM } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as taskQueue from \"../task-queue.ts\";\nimport { getTimestamp } from \"../store.ts\";\nimport { getClockSkew } from \"../skewed-date.ts\";\nimport Expression, { Value } from \"../../lib/common/expression.ts\";\nimport memoize from \"../../lib/common/memoize.ts\";\nimport timeAgo from \"../timeago.ts\";\nimport { icon } from \"../tailwind-utility-components.ts\";\n\nconst evaluateParam = memoize(\n  (\n    exp: Expression,\n    obj: any,\n    now: number,\n  ): { value: Value; timestamp: number; parameter: string } => {\n    let timestamp = now;\n    const valueMap: Map<Expression.Literal, string> = new Map();\n    const lit = exp.evaluate((e): Expression.Literal => {\n      if (e instanceof Expression.Literal) return e;\n      if (e instanceof Expression.Parameter) {\n        let v = obj[e.path.toString()];\n        if (v) {\n          timestamp = Math.min(\n            timestamp,\n            obj[e.path.toString() + \":valueTimestamp\"] ?? 0,\n          );\n          const t = obj[e.path.toString() + \":type\"];\n          if (t === \"xsd:dateTime\" && typeof v === \"number\")\n            v = new Date(v).toLocaleString();\n          const val = new Expression.Literal(v);\n          valueMap.set(val, e.path.toString());\n          return val;\n        }\n      } else if (e instanceof Expression.FunctionCall) {\n        if (e.name === \"NOW\") return new Expression.Literal(now);\n        else if (e.name === \"DATE_STRING\") {\n          const v = e.args[0];\n          if (v instanceof Expression.Literal) {\n            return new Expression.Literal(\n              new Date(v.value as string | number).toLocaleString(),\n            );\n          }\n        }\n      }\n      return new Expression.Literal(null);\n    });\n\n    return { value: lit.value, timestamp, parameter: valueMap.get(lit) };\n  },\n);\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs[\"device\"];\n\n      const { value, timestamp, parameter } = evaluateParam(\n        vnode.attrs[\"parameter\"],\n        device,\n        getTimestamp() + getClockSkew(),\n      );\n\n      if (value == null) return null;\n\n      let edit;\n      if (device[parameter + \":writable\"]) {\n        edit = m(\n          \"button\",\n          {\n            title: \"Edit parameter value\",\n            onclick: () => {\n              taskQueue.stageSpv({\n                name: \"setParameterValues\",\n                devices: [device[\"DeviceID.ID\"]],\n                parameterValues: [\n                  [parameter, device[parameter], device[parameter + \":type\"]],\n                ],\n              });\n            },\n          },\n          m(icon, {\n            name: \"edit\",\n            class: \"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\",\n          }),\n        );\n      }\n\n      const el = m(\"long-text\", { text: `${value}` });\n\n      return m(\n        \"span.inline-flex overflow-hidden align-top\",\n        {\n          onmouseover: (e) => {\n            e.redraw = false;\n            // Don't update any child element\n            if (e.target === (el as VnodeDOM).dom) {\n              const now = Date.now() + getClockSkew();\n              const localeString = new Date(timestamp).toLocaleString();\n              e.target.title = `${localeString} (${timeAgo(now - timestamp)})`;\n            }\n          },\n        },\n        m(\"span.truncate\", el),\n        edit,\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/ping.ts",
    "content": "import { ClosureComponent, Component, VnodeDOM } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as store from \"../store.ts\";\n\nconst REFRESH_INTERVAL = 3000;\n\nconst component: ClosureComponent = (vn): Component => {\n  let interval: ReturnType<typeof setInterval>;\n  let host: string;\n\n  const refresh = (): void => {\n    if (!host) {\n      const dom = (vn as VnodeDOM).dom;\n      if (dom) dom.innerHTML = \"\";\n      return;\n    }\n\n    let status = \"\";\n    store\n      .ping(host)\n      .then((res) => {\n        if (res[\"avg\"] != null) status = `${Math.trunc(res[\"avg\"])} ms`;\n        else status = \"Unreachable\";\n      })\n      .catch(() => {\n        status = \"Error!\";\n        clearInterval(interval);\n      })\n      .finally(() => {\n        const dom = (vn as VnodeDOM).dom;\n        if (dom) dom.innerHTML = `Pinging ${host}: ${status}`;\n      });\n  };\n\n  return {\n    onremove: () => {\n      clearInterval(interval);\n    },\n    view: (vnode) => {\n      const device = vnode.attrs[\"device\"];\n      let param =\n        device[\"InternetGatewayDevice.ManagementServer.ConnectionRequestURL\"];\n      if (!param)\n        param = device[\"Device.ManagementServer.ConnectionRequestURL\"];\n\n      let h;\n      try {\n        const url = new URL(param.value[0]);\n        h = url.hostname;\n      } catch {\n        // Ignore\n      }\n\n      if (host !== h) {\n        host = h;\n        clearInterval(interval);\n        if (host) {\n          refresh();\n          interval = setInterval(refresh, REFRESH_INTERVAL);\n        }\n      }\n\n      return m(\"div.text-sm my-4\", host ? `Pinging ${host}:` : \"\");\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/summon-button.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as taskQueue from \"../task-queue.ts\";\nimport * as store from \"../store.ts\";\nimport { invalidate } from \"../reactive-store.ts\";\nimport * as notifications from \"../notifications.ts\";\nimport Expression from \"../../lib/common/expression.ts\";\nimport { FlatDevice } from \"../../lib/ui/db.ts\";\n\ninterface Attrs {\n  device: FlatDevice;\n  parameters: Record<string, Expression>;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs.device;\n\n      return m(\n        \"button\",\n        {\n          class:\n            \"px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-sm shadow-xs text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\",\n          title: \"Initiate session and refresh basic parameters\",\n          onclick: (e) => {\n            e.target.disabled = true;\n            const params = Object.values(vnode.attrs[\"parameters\"])\n              .map((exp) => {\n                if (exp instanceof Expression.Parameter)\n                  return exp.path.toString();\n                return null;\n              })\n              .filter((exp) => !!exp);\n\n            const task = {\n              name: \"getParameterValues\",\n              parameterNames: params,\n              device: device[\"DeviceID.ID\"] as string,\n            };\n\n            taskQueue\n              .commit(\n                [task],\n                (deviceId, err, connectionRequestStatus, tasks2) => {\n                  if (err) {\n                    notifications.push(\"error\", `${deviceId}: ${err.message}`);\n                    return;\n                  }\n\n                  for (const t of tasks2)\n                    if (t.status === \"stale\") taskQueue.deleteTask(t);\n\n                  if (connectionRequestStatus !== \"OK\") {\n                    notifications.push(\n                      \"error\",\n                      `${deviceId}: ${connectionRequestStatus}`,\n                    );\n                  } else if (tasks2[0].status === \"stale\") {\n                    notifications.push(\n                      \"error\",\n                      `${deviceId}: No contact from device`,\n                    );\n                  } else if (tasks2[0].status === \"fault\") {\n                    notifications.push(\"error\", `${deviceId}: Refresh faulted`);\n                  } else {\n                    notifications.push(\"success\", `${deviceId}: Summoned`);\n                  }\n                },\n              )\n              .then(() => {\n                e.target.disabled = false;\n                store.setTimestamp(Date.now());\n                invalidate(Date.now());\n              })\n              .catch((err) => {\n                e.target.disabled = false;\n                notifications.push(\"error\", err.message);\n              });\n          },\n        },\n        \"Summon\",\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components/tags.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"../components.ts\";\nimport * as notifications from \"../notifications.ts\";\nimport * as store from \"../store.ts\";\nimport { invalidate } from \"../reactive-store.ts\";\nimport { icon } from \"../tailwind-utility-components.ts\";\nimport { decodeTag } from \"../../lib/util.ts\";\nimport Expression from \"../../lib/common/expression.ts\";\nimport { FlatDevice } from \"../../lib/ui/db.ts\";\n\ninterface Attrs {\n  device: FlatDevice;\n  writable?: Expression;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      const device = vnode.attrs.device;\n      let writable = true;\n      if (\"writable\" in vnode.attrs)\n        writable = !!store.evaluateExpression(vnode.attrs.writable, device)\n          .value;\n\n      const tags = [];\n      for (const p of Object.keys(device))\n        if (p.startsWith(\"Tags.\") && p.lastIndexOf(\":\") === -1)\n          tags.push(decodeTag(p.slice(5)));\n\n      tags.sort();\n\n      if (!writable) {\n        return m(\n          \"div\",\n          tags.map((t) =>\n            m(\n              \"span\",\n              {\n                class:\n                  \"inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 mr-2 -my-0.5 ring-1 ring-yellow-200\",\n              },\n              t,\n            ),\n          ),\n        );\n      }\n\n      return m(\n        \"div\",\n        tags.map((tag) =>\n          m(\n            \"span\",\n            {\n              class:\n                \"inline-flex items-center pl-3 pr-1 py-0.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 mr-2 ring-1 ring-yellow-200\",\n            },\n            tag,\n            m(\n              \"button\",\n              {\n                title: \"Remove tag\",\n                class:\n                  \"flex-shrink-0 ml-0.5 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-hidden focus:bg-yellow-500 focus:text-white\",\n                onclick: (e) => {\n                  e.target.disabled = true;\n                  const deviceId = device[\"DeviceID.ID\"] as string;\n                  store\n                    .updateTags(deviceId, { [tag]: false })\n                    .then(() => {\n                      e.target.disabled = false;\n                      notifications.push(\n                        \"success\",\n                        `${deviceId}: Tags updated`,\n                      );\n                      store.setTimestamp(Date.now());\n                      invalidate(Date.now());\n                    })\n                    .catch((err) => {\n                      e.target.disabled = false;\n                      notifications.push(\n                        \"error\",\n                        `${deviceId}: ${err.message}`,\n                      );\n                    });\n                },\n              },\n              m(\"span.sr-only\", \"Remove tag\"),\n              m(icon, {\n                name: \"remove\",\n                class: \"h-4 w-4\",\n              }),\n            ),\n          ),\n        ),\n        m(\n          \"span\",\n          {\n            class:\n              \"inline-flex items-center pl-1 pr-1 py-0.5 rounded-full text-sm font-medium bg-yellow-50 ring-1 ring-yellow-200\",\n          },\n          m.trust(\"&#x200B;\"),\n          m(\n            \"button\",\n            {\n              title: \"Add tag\",\n              class:\n                \"flex-shrink-0 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-hidden focus:bg-yellow-500 focus:text-white\",\n              onclick: (e) => {\n                e.target.disabled = true;\n                const deviceId = device[\"DeviceID.ID\"] as string;\n                const tag = prompt(`Enter tag to assign to device:`);\n                if (!tag) {\n                  e.target.disabled = false;\n                  return;\n                }\n\n                store\n                  .updateTags(deviceId, { [tag]: true })\n                  .then(() => {\n                    e.target.disabled = false;\n                    notifications.push(\"success\", `${deviceId}: Tags updated`);\n                    store.setTimestamp(Date.now());\n                    invalidate(Date.now());\n                  })\n                  .catch((err) => {\n                    e.target.disabled = false;\n                    notifications.push(\"error\", `${deviceId}: ${err.message}`);\n                  });\n              },\n            },\n            m(\"span.sr-only\", \"Add tag\"),\n            m(icon, {\n              name: \"add\",\n              class: \"h-4 w-4\",\n            }),\n          ),\n        ),\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/components.ts",
    "content": "import m, {\n  Static,\n  Attributes,\n  Children,\n  ComponentTypes,\n  CommonAttributes,\n  ClosureComponent,\n  Vnode,\n} from \"mithril\";\nimport parameter from \"./components/parameter.ts\";\nimport parameterList from \"./components/parameter-list.ts\";\nimport parameterTable from \"./components/parameter-table.ts\";\nimport overviewDot from \"./components/overview-dot.ts\";\nimport container from \"./components/container.ts\";\nimport summonButton from \"./components/summon-button.ts\";\nimport deviceFaults from \"./components/device-faults.ts\";\nimport allParameters from \"./components/all-parameters.ts\";\nimport deviceActions from \"./components/device-actions.ts\";\nimport tags from \"./components/tags.ts\";\nimport ping from \"./components/ping.ts\";\nimport deviceLink from \"./components/device-link.ts\";\nimport longTextComponent from \"./long-text-component.ts\";\nimport loading from \"./components/loading.ts\";\n\nconst comps = {\n  parameter,\n  \"parameter-list\": parameterList,\n  \"parameter-table\": parameterTable,\n  \"overview-dot\": overviewDot,\n  container,\n  \"summon-button\": summonButton,\n  \"device-faults\": deviceFaults,\n  \"all-parameters\": allParameters,\n  \"device-actions\": deviceActions,\n  tags,\n  ping,\n  \"device-link\": deviceLink,\n  \"long-text\": longTextComponent,\n  loading: loading,\n};\n\nconst contextifiedComponents = new WeakMap<ComponentTypes, ComponentTypes>();\nconst vnodeContext = new WeakMap<Vnode, Attributes>();\n\ninterface MC extends Static {\n  context: {\n    (\n      ctx: Attributes,\n      selector: string,\n      ...children: Children[]\n    ): Vnode<any, any>;\n    (\n      ctx: Attributes,\n      selector: string,\n      attributes: Attributes,\n      ...children: Children[]\n    ): Vnode<any, any>;\n    <Attrs, State>(\n      ctx: Attributes,\n      component: ComponentTypes<Attrs, State>,\n      ...args: Children[]\n    ): Vnode<Attrs, State>;\n    <Attrs, State>(\n      ctx: Attributes,\n      component: ComponentTypes<Attrs, State>,\n      attributes: Attrs & CommonAttributes<Attrs, State>,\n      ...args: Children[]\n    ): Vnode<Attrs, State>;\n  };\n}\n\nconst M = new Proxy(m, {\n  apply: (target, thisArg, argumentsList) => {\n    const c = argumentsList[0];\n    if (typeof c !== \"string\") argumentsList[0] = contextifyComponent(c);\n    else if (comps[c]) argumentsList[0] = contextifyComponent(comps[c]);\n\n    return Reflect.apply(target, undefined, argumentsList);\n  },\n  get: (target, prop) => {\n    if (prop === \"context\") return contextFn;\n    else return Reflect.get(target, prop);\n  },\n}) as MC;\n\nfunction contextFn(context, ...argumentsList): Vnode {\n  const vnode = Reflect.apply(M, undefined, argumentsList);\n  vnodeContext.set(vnode, context);\n  return vnode;\n}\n\nfunction applyContext(vnode, parentContext): void {\n  if (Array.isArray(vnode)) {\n    for (const c of vnode) applyContext(c, parentContext);\n  } else if (vnode && typeof vnode === \"object\" && vnode.tag) {\n    const vc = Object.assign({}, parentContext, vnodeContext.get(vnode));\n    if (typeof vnode.tag !== \"string\") {\n      vnodeContext.set(vnode, vc);\n      vnode.attrs = Object.assign({}, vc, vnode.attrs);\n    }\n    if (vnode.children?.length)\n      for (const c of vnode.children) applyContext(c, vc);\n  }\n}\n\nexport function contextifyComponent(component: ComponentTypes): ComponentTypes {\n  let c = contextifiedComponents.get(component);\n  if (!c) {\n    if (typeof component !== \"function\") {\n      c = Object.assign({}, component);\n      const view = component.view;\n      c.view = function (vnode) {\n        const context = vnodeContext.get(vnode) || {};\n        const res = Reflect.apply(view, this, [vnode]);\n        applyContext(res, context);\n        return res;\n      };\n    } else if (!component.prototype?.view) {\n      c = (initialNode) => {\n        const state = (component as ClosureComponent)(initialNode);\n        const view = state.view;\n        state.view = function (vnode) {\n          const context = vnodeContext.get(vnode) || {};\n          try {\n            const res = Reflect.apply(view, this, [vnode]);\n            applyContext(res, context);\n            return res;\n          } catch (err) {\n            return m(\n              \"p.text-sm font-bold text-red-500 cursor-pointer\",\n              {\n                title: \"Click to print stack trace to console\",\n                onclick: () => console.error(err),\n              },\n              \"Error!\",\n            );\n          }\n        };\n        return state;\n      };\n    } else {\n      // TODO support class components\n      throw new Error(\"Class components not supported\");\n    }\n    contextifiedComponents.set(component, c);\n  }\n  return c;\n}\n\nexport { M as m };\n"
  },
  {
    "path": "ui/config-functions.ts",
    "content": "interface Config {\n  _id: string;\n  value: string;\n}\n\ninterface Diff {\n  add: Config[];\n  remove: string[];\n}\n\nexport function flattenConfig(config: Record<string, unknown>): any {\n  const flatten = {};\n  const recuresive = (obj: any, root: string): void => {\n    for (const [k, v] of Object.entries(obj)) {\n      const key = root ? `${root}.${k}` : k;\n      if (v === undefined) continue;\n      if (v === null || typeof v !== \"object\") flatten[key] = v;\n      else recuresive(v, key);\n    }\n  };\n\n  if (config !== null && typeof config === \"object\") recuresive(config, \"\");\n  return flatten;\n}\n\n// Order keys such that nested objects come last\nfunction orderKeys(config: any): number {\n  let res = 1;\n  if (config == null || typeof config !== \"object\") return res;\n  if (Array.isArray(config)) {\n    for (const c of config) res += orderKeys(c);\n    return res;\n  }\n\n  const weights: [string, number][] = Object.entries(config).map(([k, v]) => [\n    k,\n    orderKeys(v),\n  ]);\n\n  weights.sort((a, b) => {\n    if (a[1] !== b[1]) return a[1] - b[1];\n    if (b[0] > a[0]) return -1;\n    else return 1;\n  });\n\n  for (const [k, w] of weights) {\n    res += w;\n    const v = config[k];\n    delete config[k];\n    config[k] = v;\n  }\n  return res;\n}\n\nexport function structureConfig(config: Config[]): any {\n  config.sort((a, b) => (a._id > b._id ? 1 : a._id < b._id ? -1 : 0));\n  const _config = {};\n  for (const c of config) {\n    const keys = c._id.split(\".\");\n    let ref = _config;\n    while (keys.length > 1) {\n      const k = keys.shift();\n      if (ref[k] == null || typeof ref[k] !== \"object\") ref[k] = {};\n      ref = ref[k];\n    }\n    ref[keys[0]] = c.value;\n  }\n\n  const toArray = function (object): any {\n    const MAX_BITS = 30;\n    const MAX_ARRAY_SIZE = MAX_BITS * 10;\n\n    if (object == null || typeof object !== \"object\") return object;\n\n    if (Object.keys(object).length <= MAX_ARRAY_SIZE) {\n      let indexes = [];\n      for (const key of Object.keys(object)) {\n        const idx = Math.floor(+key);\n        if (idx >= 0 && idx < MAX_ARRAY_SIZE && String(idx) === key) {\n          const pos = Math.floor(idx / MAX_BITS);\n          if (!indexes[pos]) indexes[pos] = 0;\n          indexes[pos] |= 1 << (idx % MAX_BITS);\n        } else {\n          indexes = [];\n          break;\n        }\n      }\n\n      let index = 0;\n      while (indexes.length && (index = indexes.shift()) === 1073741823);\n\n      if (index && (~index & (index + 1)) === index + 1) {\n        // its an array\n        const array = [];\n        for (let i = 0; i < Object.keys(object).length; i++)\n          array[i] = object[i];\n\n        object = array;\n      }\n    }\n\n    for (const [k, v] of Object.entries(object)) object[k] = toArray(v);\n    return object;\n  };\n\n  const res = toArray(_config);\n  orderKeys(res);\n  return res;\n}\n\nexport function diffConfig(\n  current: Record<string, unknown>,\n  target: Record<string, unknown>,\n): Diff {\n  const diff = {\n    add: [],\n    remove: [],\n  };\n\n  for (const [k, v] of Object.entries(target))\n    if (v && current[k] !== v) diff.add.push({ _id: k, value: v });\n\n  for (const k of Object.keys(current)) if (!target[k]) diff.remove.push(k);\n\n  return diff;\n}\n"
  },
  {
    "path": "ui/config-page.ts",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport uiConfigComponent from \"./ui-config-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { loadCodeMirror, loadYaml } from \"./dynamic-loader.ts\";\nimport { icon } from \"./tailwind-utility-components.ts\";\n\nconst attributes = [\n  { id: \"_id\", label: \"Key\" },\n  { id: \"value\", label: \"Value\", type: \"textarea\" },\n];\n\ninterface ValidationErrors {\n  [prop: string]: string;\n}\n\nfunction putActionHandler(action, _object, isNew?): Promise<ValidationErrors> {\n  return new Promise((resolve, reject) => {\n    const object = Object.assign({}, _object);\n    if (action === \"save\") {\n      let id = object[\"_id\"] || \"\";\n      delete object[\"_id\"];\n\n      const regex = /^[0-9a-zA-Z_.-]+$/;\n      id = id.trim();\n      if (!id.match(regex)) return void resolve({ _id: \"Invalid ID\" });\n\n      try {\n        object.value = Expression.parse(object.value || \"\").toString();\n      } catch {\n        return void resolve({\n          value: \"Config value must be valid expression\",\n        });\n      }\n\n      store\n        .resourceExists(\"config\", id)\n        .then((exists) => {\n          if (exists && isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Config already exists\" });\n          }\n\n          if (!exists && !isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Config does not exist\" });\n          }\n\n          store\n            .putResource(\"config\", id, object)\n            .then(() => {\n              notifications.push(\n                \"success\",\n                `Config ${exists ? \"updated\" : \"created\"}`,\n              );\n              store.setTimestamp(Date.now());\n              resolve(null);\n            })\n            .catch(reject);\n        })\n        .catch(reject);\n    } else if (action === \"delete\") {\n      if (!confirm(\"Deleting config. Are you sure?\")) return void resolve(null);\n      store\n        .deleteResource(\"config\", object[\"_id\"])\n        .then(() => {\n          notifications.push(\"success\", \"Config deleted\");\n          store.setTimestamp(Date.now());\n          resolve(null);\n        })\n        .catch((err) => {\n          store.setTimestamp(Date.now());\n          reject(err);\n        });\n    } else {\n      reject(new Error(\"Undefined action\"));\n    }\n  });\n}\n\nconst formData = {\n  resource: \"config\",\n  attributes: attributes,\n};\n\nfunction escapeRegExp(str): string {\n  return str.replace(/[-[\\]/{}()*+?.\\\\^$|]/g, \"\\\\$&\");\n}\n\nexport function init(): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"config\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n  return new Promise((resolve, reject) => {\n    Promise.all([loadCodeMirror(), loadYaml()])\n      .then(() => {\n        resolve({});\n      })\n      .catch(reject);\n  });\n}\n\nfunction renderTable(confsResponse, searchString): Children {\n  const confs = confsResponse.value.sort((a, b) => {\n    return a._id < b._id ? -1 : 1;\n  });\n\n  let regex;\n  if (searchString) {\n    const keywords = searchString.split(\" \").filter((s) => s);\n    if (keywords.length)\n      regex = new RegExp(keywords.map((s) => escapeRegExp(s)).join(\".*\"), \"i\");\n  }\n\n  const rows = [];\n  for (const conf of confs) {\n    const attrs = {};\n    if (regex && !regex.test(conf._id) && !regex.test(conf.value))\n      attrs[\"style\"] = \"display: none;\";\n\n    const edit = m(\n      \"button\",\n      {\n        title: \"Edit config value\",\n        onclick: () => {\n          let cb: () => Children = null;\n          const comp = m(\n            putFormComponent,\n            Object.assign(\n              {\n                base: conf,\n                actionHandler: (action, object) => {\n                  return new Promise<void>((resolve) => {\n                    putActionHandler(action, object, false)\n                      .then((errors) => {\n                        const ErrorList = errors ? Object.values(errors) : [];\n                        if (ErrorList.length) {\n                          for (const err of ErrorList)\n                            notifications.push(\"error\", err);\n                        } else {\n                          overlay.close(cb);\n                        }\n                        resolve();\n                      })\n                      .catch((err) => {\n                        notifications.push(\"error\", err.message);\n                        resolve();\n                      });\n                  });\n                },\n              },\n              formData,\n            ),\n          );\n          cb = () => comp;\n          overlay.open(\n            cb,\n            () =>\n              !comp.state[\"current\"][\"modified\"] ||\n              confirm(\"You have unsaved changes. Close anyway?\"),\n          );\n        },\n      },\n      m(icon, {\n        name: \"edit\",\n        class: \"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\",\n      }),\n    );\n\n    const del = m(\n      \"button\",\n      {\n        title: \"Delete config\",\n        onclick: () => {\n          if (!confirm(`Deleting ${conf._id} config. Are you sure?`)) return;\n\n          putActionHandler(\"delete\", conf).catch((err) => {\n            throw err;\n          });\n        },\n      },\n      m(icon, {\n        name: \"remove\",\n        class: \"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900\",\n      }),\n    );\n\n    rows.push(\n      m(\n        \"tr\",\n        attrs,\n        m(\"td.pl-4 pr-2 py-2 truncate\", m(\"long-text\", { text: conf._id })),\n        m(\n          \"td.px-2 py-2 text-right truncate\",\n          m(\"long-text\", { text: `${conf.value}` }),\n        ),\n        m(\"td.pl-2 pr-4 py-2 w-max\", edit, del),\n      ),\n    );\n  }\n\n  if (!rows.length) rows.push(m(\"tr\", m(\"td\", { colspan: 3 }, \"No config\")));\n\n  return m(\n    \"table.w-full table-fixed font-mono text-sm text-stone-700\",\n    m(\"tbody.bg-white divide-y divide-stone-200\", rows),\n  );\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Config - GenieACS\";\n\n      const search = m(\n        \"input.appearance-none block w-full px-3 py-2 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md mt-1 max-w-screen-sm shadow-sm mb-5\",\n        {\n          type: \"text\",\n          placeholder: \"Search config\",\n          oninput: (e) => {\n            vnode.state[\"searchString\"] = e.target.value;\n            e.redraw = false;\n            clearTimeout(vnode.state[\"timeout\"]);\n            vnode.state[\"timeout\"] = setTimeout(m.redraw, 250);\n          },\n        },\n      );\n\n      const confs = store.fetch(\"config\", new Expression.Literal(true));\n\n      let newConfig;\n      const subs = [];\n\n      if (window.authorizer.hasAccess(\"config\", 3)) {\n        newConfig = m(\n          \"button.mr-4 px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\",\n          {\n            title: \"Create new config\",\n            onclick: () => {\n              let cb: () => Children = null;\n              const comp = m(\n                putFormComponent,\n                Object.assign(\n                  {\n                    actionHandler: (action, object) => {\n                      return new Promise<void>((resolve) => {\n                        putActionHandler(action, object, true)\n                          .then((errors) => {\n                            const errorList = errors\n                              ? Object.values(errors)\n                              : [];\n                            if (errorList.length) {\n                              for (const err of errorList)\n                                notifications.push(\"error\", err);\n                            } else {\n                              overlay.close(cb);\n                            }\n                            resolve(null);\n                          })\n                          .catch((err) => {\n                            notifications.push(\"error\", err.message);\n                            resolve();\n                          });\n                      });\n                    },\n                  },\n                  formData,\n                ),\n              );\n              cb = () => comp;\n              overlay.open(\n                cb,\n                () =>\n                  !comp.state[\"current\"][\"modified\"] ||\n                  confirm(\"You have unsaved changes. Close anyway?\"),\n              );\n            },\n          },\n          \"New config\",\n        );\n\n        const subsData = [\n          { name: \"overview\", prefix: \"ui.overview.groups.\", data: [] },\n          { name: \"charts\", prefix: \"ui.overview.charts.\", data: [] },\n          { name: \"filters\", prefix: \"ui.filters.\", data: [] },\n          { name: \"index page\", prefix: \"ui.index.\", data: [] },\n          { name: \"device page\", prefix: \"ui.device.\", data: [] },\n        ];\n\n        if (confs.fulfilled) {\n          for (const conf of confs.value) {\n            for (const sub of subsData) {\n              if (conf[\"_id\"].startsWith(sub[\"prefix\"])) {\n                sub[\"data\"].push(conf);\n                break;\n              }\n            }\n          }\n        }\n\n        for (const sub of subsData) {\n          const attrs = { prefix: sub.prefix, name: sub.name, data: sub.data };\n          subs.push(\n            m(\n              \"button.mr-4 px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\",\n              {\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    uiConfigComponent,\n                    Object.assign(\n                      {\n                        onUpdate: (errs: Record<string, string>) => {\n                          const errors = errs ? Object.values(errs) : [];\n                          if (errors.length) {\n                            for (const err of errors)\n                              notifications.push(\"error\", err);\n                          } else {\n                            notifications.push(\n                              \"success\",\n                              `${sub.name.replace(\n                                /^[a-z]/,\n                                sub.name[0].toUpperCase(),\n                              )} config updated`,\n                            );\n                            overlay.close(cb);\n                          }\n                          store.setTimestamp(Date.now());\n                        },\n                        onError: (err) => {\n                          notifications.push(\"error\", err.message);\n                          store.setTimestamp(Date.now());\n                          overlay.close(cb);\n                        },\n                      },\n                      attrs,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              `Edit ${sub.name}`,\n            ),\n          );\n        }\n      }\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing config\"),\n        m(\n          \"loading\",\n          { queries: [confs] },\n          m(\n            \"div\",\n            search,\n            m(\n              \".shadow-sm overflow-hidden border-b border-stone-200 rounded-lg bg-white\",\n              m(\n                \".overflow-y-scroll h-96\",\n                renderTable(confs, vnode.state[\"searchString\"]),\n              ),\n            ),\n            m(\".mt-5\", [newConfig].concat(subs)),\n          ),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/config.ts",
    "content": "import Expression from \"../lib/common/expression.ts\";\nexport const configSnapshot = window.configSnapshot;\nexport const genieacsVersion = window.genieacsVersion;\n\ntype Filters = { label: string; parameter: Expression; type: string }[];\ntype pageSize = number;\ntype overview = {\n  charts: {\n    [name: string]: {\n      label: string;\n      slices: {\n        label: string;\n        filter: Expression;\n        color: string;\n      }[];\n    };\n  };\n  groups: {\n    label: string;\n    charts: string[];\n  }[];\n};\n\ntype Index = {\n  label: string;\n  type?: string;\n  parameter: Expression;\n  unsortable: boolean;\n  raw: NestedRecord;\n}[];\n\ntype NestedRecord = { [k: string]: Expression | NestedRecord };\n\nconst conf: NestedRecord = {};\nfor (const [key, value] of Object.entries(window.clientConfig)) {\n  const exp = Expression.parse(value).evaluate((e) => e);\n  let ref = conf;\n  const keyParts = key.split(\".\");\n  while (keyParts.length > 1) {\n    const k = keyParts.shift();\n    if (ref[k] == null || typeof ref[k] !== \"object\") ref[k] = {};\n    ref = ref[k] as NestedRecord;\n  }\n  ref[keyParts[0]] = exp;\n}\n\nexport const filters: Filters = [];\nexport let pageSize: number = 10;\nexport const overview: overview = { charts: {}, groups: [] };\nexport const index: Index = [];\nexport let device: NestedRecord = {};\n\nfor (const obj of Object.values(conf[\"filters\"] || {})) {\n  let label = \"\";\n  let parameter: Expression = new Expression.Literal(false);\n  let type = \"string\";\n  if (obj[\"label\"] instanceof Expression.Literal)\n    label = obj[\"label\"].value as string;\n  if (obj[\"parameter\"] instanceof Expression) parameter = obj[\"parameter\"];\n  if (obj[\"type\"] instanceof Expression.Literal)\n    type = obj[\"type\"].value as string;\n  filters.push({ label, parameter, type });\n}\n\nfor (const obj of Object.values(conf[\"index\"] || {})) {\n  let label = \"\";\n  let parameter: Expression = new Expression.Literal(null);\n  let unsortable = false;\n  let type = \"\";\n  if (obj[\"label\"] instanceof Expression.Literal)\n    label = obj[\"label\"].value as string;\n  if (obj[\"type\"] instanceof Expression.Literal)\n    type = obj[\"type\"].value as string;\n  if (obj[\"parameter\"] instanceof Expression) parameter = obj[\"parameter\"];\n  if (obj[\"unsortable\"] instanceof Expression.Literal)\n    unsortable = obj[\"unsortable\"].value as boolean;\n  index.push({ label, type, parameter, unsortable, raw: obj });\n}\n\nfor (const obj of Object.values(conf[\"overview\"]?.[\"groups\"] || {})) {\n  let label = \"\";\n  const charts: string[] = [];\n  if (obj[\"label\"] instanceof Expression.Literal)\n    label = obj[\"label\"].value as string;\n  for (const chart of Object.values(obj[\"charts\"] || {})) {\n    if (chart instanceof Expression.Literal) charts.push(chart.value as string);\n  }\n  overview.groups.push({ label, charts });\n}\n\nfor (const [name, obj] of Object.entries(conf[\"overview\"]?.[\"charts\"] || {})) {\n  const slices: { label: string; filter: Expression; color: string }[] = [];\n  for (const slice of Object.values(obj[\"slices\"] || {})) {\n    let label = \"\";\n    let filter: Expression = new Expression.Literal(false);\n    let color = \"\";\n    if (slice[\"label\"] instanceof Expression.Literal)\n      label = slice[\"label\"].value as string;\n    if (slice[\"filter\"] instanceof Expression) filter = slice[\"filter\"];\n    if (slice[\"color\"] instanceof Expression.Literal)\n      color = slice[\"color\"].value as string;\n    slices.push({ label, filter, color });\n  }\n  let label = \"\";\n  if (obj[\"label\"] instanceof Expression.Literal)\n    label = obj[\"label\"].value as string;\n  overview.charts[name] = { label, slices };\n}\n\nif (conf[\"pageSize\"] instanceof Expression.Literal)\n  pageSize = +conf[\"pageSize\"].value || 10;\n\ndevice = conf[\"device\"] as NestedRecord;\n\n// Raw config values for checking if views are configured\nexport const rawConf = conf;\n"
  },
  {
    "path": "ui/css/app.css",
    "content": "@import \"tailwindcss\";\n@plugin \"@tailwindcss/forms\";\n\n@source inline(\"h-full bg-stone-100\");\n@source \"../../ui/**/*.{ts,tsx}\";\n\n@theme {\n  --font-sans:\n    \"Inter\", ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\",\n    \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n  --font-mono:\n    \"Roboto Mono\", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,\n    \"Liberation Mono\", \"Courier New\", monospace;\n\n  /* Reset default color palette to match v3 exclusive palette */\n  --color-*: initial;\n\n  --color-black: #000;\n  --color-white: #fff;\n\n  --color-cyan-50: #edfdfe;\n  --color-cyan-100: #d3f7fa;\n  --color-cyan-200: #aceef5;\n  --color-cyan-300: #72e0ee;\n  --color-cyan-400: #31c8df;\n  --color-cyan-500: #15abc5;\n  --color-cyan-600: #1589a5;\n  --color-cyan-700: #186e86;\n  --color-cyan-800: #1c5a6e;\n  --color-cyan-900: #1c4b5d;\n\n  --color-stone-50: #faf8f8;\n  --color-stone-100: #f5f2f0;\n  --color-stone-200: #e7e4e2;\n  --color-stone-300: #d6d3d1;\n  --color-stone-400: #a8a29e;\n  --color-stone-500: #78716c;\n  --color-stone-600: #57534e;\n  --color-stone-700: #44403c;\n  --color-stone-800: #292524;\n  --color-stone-900: #1c1917;\n\n  --color-red-50: #fdf3f3;\n  --color-red-100: #fce4e4;\n  --color-red-200: #facece;\n  --color-red-300: #f5acac;\n  --color-red-400: #ee7b7b;\n  --color-red-500: #e25151;\n  --color-red-600: #ce3434;\n  --color-red-700: #ad2828;\n  --color-red-800: #902424;\n  --color-red-900: #782424;\n\n  --color-emerald-50: #edfcf5;\n  --color-emerald-100: #d4f7e5;\n  --color-emerald-200: #adedd0;\n  --color-emerald-300: #77deb5;\n  --color-emerald-400: #40c796;\n  --color-emerald-500: #1dac7d;\n  --color-emerald-600: #108b65;\n  --color-emerald-700: #0d6f53;\n  --color-emerald-800: #0d5843;\n  --color-emerald-900: #0b4938;\n\n  --color-yellow-50: #fcfbea;\n  --color-yellow-100: #faf5c7;\n  --color-yellow-200: #f5e993;\n  --color-yellow-300: #efd755;\n  --color-yellow-400: #e9c226;\n  --color-yellow-500: #d9aa19;\n  --color-yellow-600: #bb8513;\n  --color-yellow-700: #956013;\n  --color-yellow-800: #7c4c17;\n  --color-yellow-900: #6a3f19;\n\n  --color-blue-50: #f0f6fe;\n  --color-blue-100: #deeafb;\n  --color-blue-200: #c4dcf9;\n  --color-blue-300: #9bc5f5;\n  --color-blue-400: #6ca6ee;\n  --color-blue-500: #4985e8;\n  --color-blue-600: #3469dc;\n  --color-blue-700: #2b55ca;\n  --color-blue-800: #2946a4;\n  --color-blue-900: #263e82;\n\n  --background-image-stripes: repeating-linear-gradient(\n    -45deg,\n    transparent,\n    transparent 10px,\n    rgba(0, 0, 0, 0.02) 10px,\n    rgba(0, 0, 0, 0.02) 20px\n  );\n}\n\n@supports (font-variation-settings: normal) {\n  @font-face {\n    font-family: \"Roboto Mono\";\n    src: url(\"RobotoMono.woff2\") format(\"woff2-variations\");\n    font-weight: 100 700;\n    font-style: normal;\n    unicode-range:\n      U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,\n      U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n      U+FEFF, U+FFFD;\n    font-display: swap;\n  }\n\n  @font-face {\n    font-family: \"Inter\";\n    font-weight: 100 900;\n    font-display: swap;\n    font-style: normal;\n    font-named-instance: \"Regular\";\n    src: url(\"InterVariable.woff2\") format(\"woff2-variations\");\n    unicode-range:\n      U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,\n      U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n      U+FEFF, U+FFFD;\n  }\n\n  @font-face {\n    font-family: \"Inter\";\n    font-weight: 100 900;\n    font-display: swap;\n    font-style: italic;\n    font-named-instance: \"Italic\";\n    src: url(\"InterVariable-Italic.woff2\") format(\"woff2-variations\");\n    unicode-range:\n      U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,\n      U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n      U+FEFF, U+FFFD;\n  }\n}\n\n.device-page {\n  h1 {\n    @apply text-xl font-medium text-stone-900 mb-5;\n  }\n\n  h2 {\n    @apply text-lg font-semibold text-stone-900 mb-5 mt-8;\n  }\n\n  h3 {\n    @apply text-lg font-medium text-stone-900 mb-5 mt-8;\n  }\n\n  span.inform {\n    @apply flex gap-3;\n    & > button {\n      @apply -my-1.5;\n    }\n  }\n}\n\n@layer base {\n  button:not(:disabled),\n  [role=\"button\"]:not(:disabled) {\n    cursor: pointer;\n  }\n\n  :focus-visible {\n    @apply outline-2 outline-cyan-500;\n  }\n}\n"
  },
  {
    "path": "ui/datalist.ts",
    "content": "import m, { ClosureComponent, Component, Vnode } from \"mithril\";\n\nconst elements: Map<string, Vnode> = new Map();\n\n// Source: https://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/\nfunction hash(str: string): number {\n  let res = 0;\n  for (let i = 0; i < str.length; ++i) {\n    const c = str.charCodeAt(i);\n    res = (res << 5) - res + c;\n    res |= 0;\n  }\n  return res;\n}\n\nexport function getDatalistId(options: string[]): string {\n  const id = \"datalist\" + options.reduce((acc, cur) => acc ^ hash(cur), 0);\n  if (!elements.has(id)) {\n    const n = m(\n      \"datalist\",\n      { id },\n      options.map((o) => m(\"option\", { value: o })),\n    );\n    elements.set(id, n);\n  }\n  return id;\n}\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: () => {\n      return [...elements.values()];\n    },\n    onupdate: () => {\n      for (const id of elements.keys()) {\n        const used = document.querySelector(`[list='${id}']`);\n        if (!used) elements.delete(id);\n      }\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/device-page.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { device as deviceConfig } from \"./config.ts\";\nimport * as store from \"./store.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport Path from \"../lib/common/path.ts\";\nimport { ViewComponent } from \"./views.ts\";\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"devices\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  return Promise.resolve({\n    deviceId: args.id,\n    deviceFilter: new Expression.Binary(\n      \"=\",\n      new Expression.Parameter(Path.parse(\"DeviceID.ID\")),\n      new Expression.Literal(args.id as string),\n    ),\n  });\n}\n\ninterface Attrs {\n  deviceId: string;\n  deviceFilter: Expression;\n}\n\nexport const component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      document.title = `${vnode.attrs.deviceId} - Devices - GenieACS`;\n\n      const dev = store.fetch(\"devices\", vnode.attrs.deviceFilter);\n      if (!dev.value.length) {\n        if (!dev.fulfilling) {\n          return m(\n            \"p.text-sm font-bold text-red-500\",\n            `No such device ${vnode.attrs[\"deviceId\"]}`,\n          );\n        }\n        return m(\n          \"loading\",\n          { queries: [dev] },\n          m(\"div\", { style: \"height: 100px;\" }),\n        );\n      }\n\n      const conf = deviceConfig;\n      if (\n        conf instanceof Expression.Literal &&\n        typeof conf.value === \"string\"\n      ) {\n        return m(ViewComponent, {\n          name: conf.value,\n          attrs: { deviceId: vnode.attrs[\"deviceId\"] },\n        });\n      }\n\n      const cmps = [];\n\n      for (const c of Object.values(conf)) {\n        cmps.push(\n          m.context(\n            { device: dev.value[0], deviceQuery: dev },\n            store.evaluateExpression(c[\"type\"], {}).value as string,\n            c as any,\n          ),\n        );\n      }\n\n      return m(\"div.device-page\", m(\"h1\", vnode.attrs[\"deviceId\"]), cmps);\n    },\n  };\n};\n"
  },
  {
    "path": "ui/devices-page.ts",
    "content": "import { ClosureComponent, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE, index as indexConfig } from \"./config.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport * as store from \"./store.ts\";\nimport { invalidate } from \"./reactive-store.ts\";\nimport { queueTask, stageDownload } from \"./task-queue.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport Expression, { extractPaths } from \"../lib/common/expression.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport { ViewComponent } from \"./views.ts\";\n\nconst memoizedGetSortable = memoize((p: Expression) => {\n  const expressionParams = extractPaths(p);\n  if (expressionParams.length === 1) return expressionParams[0];\n  return null;\n});\n\nconst getDownloadUrl = memoize(\n  (\n    filter: Expression,\n    indexParameters: { label: string; parameter: Expression }[],\n  ) => {\n    const columns = {};\n    for (const p of indexParameters) columns[p.label] = p.parameter.toString();\n    return `api/devices.csv?${m.buildQueryString({\n      filter: filter.toString(),\n      columns: JSON.stringify(columns),\n    })}`;\n  },\n);\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"devices\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\nexport function init(args: Record<string, unknown>): Promise<Attrs> {\n  return new Promise((resolve, reject) => {\n    if (!window.authorizer.hasAccess(\"devices\", 2))\n      return void reject(new Error(\"You are not authorized to view this page\"));\n\n    let filter: Expression = null;\n    let sort: Record<string, number> = null;\n    if (args.hasOwnProperty(\"filter\"))\n      filter = Expression.parse(args[\"filter\"] as string);\n    if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n    const indexParameters = indexConfig;\n    if (!indexParameters.length) {\n      indexParameters.push({\n        label: \"ID\",\n        parameter: Expression.parse(\"DeviceID.ID\"),\n        unsortable: false,\n        raw: {},\n      });\n    }\n    resolve({ filter, indexParameters, sort });\n  });\n}\n\nfunction renderActions(selected: Set<string>): Children {\n  const buttons = [];\n\n  buttons.push(\n    m(\n      \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n      {\n        title: \"Reboot selected devices\",\n        disabled: !selected.size,\n        onclick: () => {\n          const tasks = [...selected].map((s) => ({\n            name: \"reboot\",\n            device: s,\n          }));\n          queueTask(...tasks);\n        },\n      },\n      \"Reboot\",\n    ),\n  );\n\n  buttons.push(\n    m(\n      \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n      {\n        title: \"Factory reset selected devices\",\n        disabled: !selected.size,\n        onclick: () => {\n          const tasks = [...selected].map((s) => ({\n            name: \"factoryReset\",\n            device: s,\n          }));\n          queueTask(...tasks);\n        },\n      },\n      \"Reset\",\n    ),\n  );\n\n  buttons.push(\n    m(\n      \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n      {\n        title: \"Push a firmware or a config file\",\n        disabled: !selected.size,\n        onclick: () => {\n          stageDownload({\n            name: \"download\",\n            devices: [...selected],\n          });\n        },\n      },\n      \"Push file\",\n    ),\n  );\n\n  buttons.push(\n    m(\n      \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n      {\n        title: \"Delete selected devices\",\n        disabled: !selected.size,\n        onclick: () => {\n          const ids = Array.from(selected);\n          if (!confirm(`Deleting ${ids.length} devices. Are you sure?`)) return;\n\n          let counter = 1;\n          for (const id of ids) {\n            ++counter;\n            store\n              .deleteResource(\"devices\", id)\n              .then(() => {\n                notifications.push(\"success\", `${id}: Deleted`);\n                if (--counter === 0) {\n                  store.setTimestamp(Date.now());\n                  invalidate(Date.now());\n                }\n              })\n              .catch((err) => {\n                notifications.push(\"error\", `${id}: ${err.message}`);\n                if (--counter === 0) {\n                  store.setTimestamp(Date.now());\n                  invalidate(Date.now());\n                }\n              });\n          }\n          if (--counter === 0) {\n            store.setTimestamp(Date.now());\n            invalidate(Date.now());\n          }\n        },\n      },\n      \"Delete\",\n    ),\n  );\n\n  buttons.push(\n    m(\n      \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n      {\n        title: \"Tag selected devices\",\n        disabled: !selected.size,\n        onclick: () => {\n          const ids = Array.from(selected);\n          const tag = prompt(`Enter tag to assign to ${ids.length} devices:`);\n          if (!tag) return;\n\n          let counter = 1;\n          for (const id of ids) {\n            ++counter;\n            store\n              .updateTags(id, { [tag]: true })\n              .then(() => {\n                notifications.push(\"success\", `${id}: Tags updated`);\n                if (--counter === 0) {\n                  store.setTimestamp(Date.now());\n                  invalidate(Date.now());\n                }\n              })\n              .catch((err) => {\n                notifications.push(\"error\", `${id}: ${err.message}`);\n                if (--counter === 0) {\n                  store.setTimestamp(Date.now());\n                  invalidate(Date.now());\n                }\n              });\n          }\n          if (--counter === 0) {\n            store.setTimestamp(Date.now());\n            invalidate(Date.now());\n          }\n        },\n      },\n      \"Tag\",\n    ),\n  );\n\n  buttons.push(\n    m(\n      \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n      {\n        title: \"Untag selected devices\",\n        disabled: !selected.size,\n        onclick: () => {\n          const ids = Array.from(selected);\n          const tag = prompt(\n            `Enter tag to unassign from ${ids.length} devices:`,\n          );\n          if (!tag) return;\n\n          let counter = 1;\n          for (const id of ids) {\n            ++counter;\n            store\n              .updateTags(id, { [tag]: false })\n              .then(() => {\n                notifications.push(\"success\", `${id}: Tags updated`);\n                if (--counter === 0) {\n                  store.setTimestamp(Date.now());\n                  invalidate(Date.now());\n                }\n              })\n              .catch((err) => {\n                notifications.push(\"error\", `${id}: ${err.message}`);\n                if (--counter === 0) {\n                  store.setTimestamp(Date.now());\n                  invalidate(Date.now());\n                }\n              });\n          }\n          if (--counter === 0) {\n            store.setTimestamp(Date.now());\n            invalidate(Date.now());\n          }\n        },\n      },\n      \"Untag\",\n    ),\n  );\n\n  return buttons;\n}\n\ninterface Attrs {\n  indexParameters: typeof indexConfig;\n  filter?: Expression;\n  sort?: Record<string, number>;\n}\n\nexport const component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      document.title = \"Devices - GenieACS\";\n      const attributes = vnode.attrs.indexParameters;\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter: Expression): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs.sort) ops[\"sort\"] = vnode.attrs.sort;\n        m.route.set(\"/devices\", ops);\n      }\n\n      const sort = vnode.attrs.sort || {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++) {\n        const attr = attributes[i];\n        if (attr.unsortable) continue;\n        const param = memoizedGetSortable(attr.parameter);\n        if (param) sortAttributes[i] = sort[param.toString()] || 0;\n      }\n\n      function onSortChange(sortedAttrs): void {\n        const _sort = {};\n        for (const index of sortedAttrs) {\n          const param = memoizedGetSortable(\n            attributes[Math.abs(index) - 1].parameter,\n          );\n          _sort[param.toString()] = Math.sign(index);\n        }\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/devices\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const devs = store.fetch(\"devices\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n      const count = store.count(\"devices\", filter);\n\n      const downloadUrl = getDownloadUrl(filter, attributes);\n\n      const valueCallback = (attr, device): Children => {\n        if (!attr.type && !attr.components && attr.component) {\n          return m(ViewComponent, {\n            name: attr.component,\n            attrs: {\n              ...attr,\n              deviceId: device[\"DeviceID.ID\"],\n            },\n          });\n        } else {\n          return m.context(\n            { device: device, parameter: attr.parameter },\n            attr.type || \"parameter\",\n            attr.raw,\n          );\n        }\n      };\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes.map((a) => ({\n        ...a,\n        label: a.label,\n        type: a.type,\n      }));\n      attrs[\"data\"] = devs.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n      attrs[\"valueCallback\"] = valueCallback;\n      attrs[\"recordActionsCallback\"] = (device): Children => {\n        return m(\n          \"a.text-cyan-700 hover:text-cyan-900\",\n          {\n            href: `#!/devices/${encodeURIComponent(device[\"DeviceID.ID\"])}`,\n          },\n          \"Show\",\n        );\n      };\n\n      if (window.authorizer.hasAccess(\"devices\", 3))\n        attrs[\"actionsCallback\"] = renderActions;\n\n      const filterAttrs = {\n        resource: \"devices\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing devices\"),\n        m(filterComponent, filterAttrs),\n        m(\"loading\", { queries: [devs, count] }, m(indexTableComponent, attrs)),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/drawer-component.ts",
    "content": "import m, {\n  Children,\n  Child,\n  ClosureComponent,\n  Component,\n  VnodeDOM,\n} from \"mithril\";\nimport * as store from \"./store.ts\";\nimport { invalidate } from \"./reactive-store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport { icon } from \"./tailwind-utility-components.ts\";\nimport {\n  clear,\n  commit,\n  deleteTask,\n  getQueue,\n  getStaging,\n  QueueTask,\n  queueTask,\n  StageTask,\n} from \"./task-queue.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nconst invalid: WeakSet<StageTask> = new WeakSet();\n\nfunction renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children {\n  function keydown(e: KeyboardEvent): void {\n    if (e.key === \"Enter\") queueFunc();\n    else if (e.key === \"Escape\") cancelFunc();\n    else e[\"redraw\"] = false;\n  }\n\n  let input;\n  if (task.parameterValues[0][2] === \"xsd:boolean\") {\n    input = m(\n      \"select.mt-1 w-full block pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md\",\n      {\n        value: task.parameterValues[0][1].toString(),\n        onchange: (e) => {\n          e.redraw = false;\n          task.parameterValues[0][1] = input.dom.value;\n        },\n        onkeydown: keydown,\n        oncreate: (vnode) => {\n          (vnode.dom as HTMLSelectElement).focus();\n        },\n      },\n      [\n        m(\"option\", { value: \"true\" }, \"true\"),\n        m(\"option\", { value: \"false\" }, \"false\"),\n      ],\n    );\n  } else {\n    const type = task.parameterValues[0][2];\n    let value = task.parameterValues[0][1];\n    if (type === \"xsd:dateTime\" && typeof value === \"number\")\n      value = new Date(value).toJSON() || value;\n    input = m(\n      \"input.mt-1 w-full shadow-xs focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md\",\n      {\n        type: [\"xsd:int\", \"xsd:unsignedInt\"].includes(type) ? \"number\" : \"text\",\n        value: value,\n        oninput: (e) => {\n          e.redraw = false;\n          task.parameterValues[0][1] = input.dom.value;\n        },\n        onkeydown: keydown,\n        oncreate: (vnode) => {\n          (vnode.dom as HTMLInputElement).focus();\n          (vnode.dom as HTMLInputElement).select();\n          // Need to prevent scrolling on focus because\n          // we're animating height and using overflow: hidden\n          (vnode.dom.parentNode.parentNode as Element).scrollTop = 0;\n        },\n      },\n    );\n  }\n\n  return [\n    m(\n      \"span.text-sm text-stone-700 inline-flex max-w-full gap-2\",\n      \"Editing\",\n      m(\n        \"span\",\n        {\n          title: task.parameterValues[0][0],\n          dir: \"rtl\",\n          class: \"italic pr-1 min-w-0 truncate\",\n        },\n        task.parameterValues[0][0],\n      ),\n    ),\n    input,\n  ];\n}\n\nfunction renderStagingDownload(task: StageTask): Children {\n  if (!task.fileName || !task.fileType) invalid.add(task);\n  else invalid.delete(task);\n  const files = store.fetch(\"files\", new Expression.Literal(true));\n  let oui = \"\";\n  let productClass = \"\";\n  for (const d of task.devices) {\n    const parts = d.split(\"-\");\n    if (oui === \"\") oui = parts[0];\n    else if (oui !== parts[0]) oui = null;\n    if (parts.length === 3) {\n      if (productClass === \"\") productClass = parts[1];\n      else if (productClass !== parts[1]) productClass = null;\n    }\n  }\n\n  if (oui) oui = decodeURIComponent(oui);\n  if (productClass) productClass = decodeURIComponent(productClass);\n\n  const typesList = [\n    ...new Set([\n      \"\",\n      \"1 Firmware Upgrade Image\",\n      \"2 Web Content\",\n      \"3 Vendor Configuration File\",\n      \"4 Tone File\",\n      \"5 Ringer File\",\n      ...files.value.map((f) => f[\"metadata.fileType\"]).filter((f) => f),\n    ]),\n  ].map((t) =>\n    m(\n      \"option\",\n      { disabled: !t, value: t, selected: (task.fileType || \"\") === t },\n      t,\n    ),\n  );\n\n  const filesList = [\"\"]\n    .concat(\n      files.value\n        .filter(\n          (f) =>\n            (!f[\"metadata.oui\"] || f[\"metadata.oui\"] === oui) &&\n            (!f[\"metadata.productClass\"] ||\n              f[\"metadata.productClass\"] === productClass),\n        )\n        .map((f) => f._id),\n    )\n    .map((f) =>\n      m(\n        \"option\",\n        { disabled: !f, value: f, selected: (task.fileName || \"\") === f },\n        f,\n      ),\n    );\n\n  return m(\"div.flex items-center gap-2 text-sm text-stone-700 max-w-full\", [\n    \"Push\",\n    m(\n      \"select\",\n      {\n        class:\n          \"min-w-0 pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md\",\n        onchange: (e) => {\n          const f = e.target.value;\n          task.fileName = f;\n          task.fileType = \"\";\n          for (const file of files.value)\n            if (file._id === f) task.fileType = file[\"metadata.fileType\"];\n        },\n        disabled: files.fulfilling,\n      },\n      filesList,\n    ),\n    \"as\",\n    m(\n      \"select\",\n      {\n        class:\n          \"min-w-0 pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md\",\n        onchange: (e) => {\n          task.fileType = e.target.value;\n        },\n      },\n      typesList,\n    ),\n  ]);\n}\n\nfunction renderStaging(staging: Set<StageTask>): Child[] {\n  const elements: Child[] = [];\n\n  for (const s of staging) {\n    const queueFunc = (): void => {\n      staging.delete(s);\n      for (const d of s.devices) {\n        const t = Object.assign({ device: d }, s);\n        delete t.devices;\n        queueTask(t);\n      }\n    };\n    const cancelFunc = (): void => {\n      staging.delete(s);\n    };\n\n    let elms;\n    if (s.name === \"setParameterValues\")\n      elms = renderStagingSpv(s, queueFunc, cancelFunc);\n    else if (s.name === \"download\") elms = renderStagingDownload(s);\n\n    const queue = m(\n      \"button\",\n      {\n        class:\n          \"px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-sm shadow-xs text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:cursor-not-allowed disabled:opacity-50\",\n        title: \"Queue task\",\n        onclick: queueFunc,\n        disabled: invalid.has(s),\n      },\n      \"Queue\",\n    );\n    const cancel = m(\n      \"button\",\n      {\n        class:\n          \"px-2.5 py-1.5 border border-stone-300 shadow-xs text-xs font-medium rounded-sm text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:cursor-not-allowed disabled:opacity-50\",\n        title: \"Cancel edit\",\n        onclick: cancelFunc,\n      },\n      \"Cancel\",\n    );\n\n    elements.push(\n      m(\n        \"div.p-4\",\n        elms,\n        m(\"div.flex mt-4 justify-center gap-4\", cancel, queue),\n      ),\n    );\n  }\n  return elements;\n}\n\nfunction renderQueue(queue: Set<QueueTask>): Child[] {\n  const details: Child[] = [];\n  const devices: { [deviceId: string]: any[] } = {};\n  for (const t of queue) {\n    devices[t.device] = devices[t.device] || [];\n    devices[t.device].push(t);\n  }\n\n  for (const [k, v] of Object.entries(devices)) {\n    details.push(m(\"h3.font-semibold text-stone-700\", k));\n    for (const t of v) {\n      const actions: ReturnType<typeof m>[] = [];\n      let task: ReturnType<typeof m>;\n\n      if (t.status === \"fault\" || t.status === \"stale\") {\n        actions.push(\n          m(\n            \"button\",\n            {\n              title: \"Retry this task\",\n              onclick: () => {\n                queueTask(t);\n              },\n            },\n            m(icon, {\n              name: \"retry\",\n              class: \"inline h-4 w-4 text-cyan-700 hover:text-cyan-900\",\n            }),\n          ),\n        );\n      }\n\n      actions.push(\n        m(\n          \"button\",\n          {\n            title: \"Remove this task\",\n            onclick: () => {\n              deleteTask(t);\n            },\n          },\n          m(icon, {\n            name: \"remove\",\n            class: \"inline h-4 w-4 text-cyan-700 hover:text-cyan-900\",\n          }),\n        ),\n      );\n\n      if (t.name === \"setParameterValues\") {\n        task = m(\n          \"span.text-stone-900 inline-flex max-w-full gap-2\",\n          \"Set\",\n          m(\n            \"span\",\n            {\n              title: t.parameterValues[0][0],\n              dir: \"rtl\",\n              class: \"italic pr-1 min-w-0 truncate\",\n            },\n            t.parameterValues[0][0],\n          ),\n          \"to\",\n          m(\n            \"span\",\n            {\n              title: t.parameterValues[0][1],\n              class: \"min-w-0 truncate\",\n            },\n            t.parameterValues[0][1],\n          ),\n        );\n      } else if (t.name === \"refreshObject\") {\n        task = m(\n          \"span.text-stone-900 inline-flex max-w-full gap-2\",\n          \"Refresh\",\n          m(\n            \"span\",\n            {\n              title: t.parameterName,\n              dir: \"rtl\",\n              class: \"italic pr-1 min-w-0 truncate\",\n            },\n            t.parameterName,\n          ),\n        );\n      } else if (t.name === \"reboot\") {\n        task = m(\"span.text-stone-900\", \"Reboot\");\n      } else if (t.name === \"factoryReset\") {\n        task = m(\"span.text-stone-900\", \"Factory reset\");\n      } else if (t.name === \"addObject\") {\n        task = m(\n          \"span.text-stone-900 inline-flex max-w-full gap-2\",\n          \"Add\",\n          m(\n            \"span\",\n            {\n              title: t.objectName,\n              dir: \"rtl\",\n              class: \"italic pr-1 min-w-0 truncate\",\n            },\n            t.objectName,\n          ),\n        );\n      } else if (t.name === \"deleteObject\") {\n        task = m(\n          \"span.text-stone-900 inline-flex max-w-full gap-2\",\n          \"Delete\",\n          m(\n            \"span\",\n            {\n              title: t.objectName,\n              dir: \"rtl\",\n              class: \"italic pr-1 min-w-0 truncate\",\n            },\n            t.objectName,\n          ),\n        );\n      } else if (t.name === \"getParameterValues\") {\n        task = m(\n          \"span.text-stone-900\",\n          `Refresh ${t.parameterNames.length} parameters`,\n        );\n      } else if (t.name === \"download\") {\n        task = m(\n          \"span.text-stone-900\",\n          `Push file: ${t.fileName} (${t.fileType})`,\n        );\n      } else {\n        task = m(\"span.text-stone-900\", t.name);\n      }\n\n      let bgDiv: ReturnType<typeof m>;\n      if (t.status === \"pending\") {\n        bgDiv = m(\n          \"div.block absolute inset-0 bg-emerald-200 rounded-sm animate-pulse\",\n          \"\",\n        );\n      } else if (t.status === \"fault\") {\n        bgDiv = m(\"div.block absolute inset-0 bg-red-200 rounded-sm\", \"\");\n      } else if (t.status === \"stale\") {\n        bgDiv = m(\"div.block absolute inset-0 bg-stone-200 rounded-sm\", \"\");\n      }\n\n      details.push(\n        m(\n          \"div.flex justify-between w-full rounded-sm items-center relative\",\n          bgDiv,\n          m(\"div.overflow-hidden relative\", task),\n          m(\"div.flex whitespace-nowrap gap-2 ml-2 relative\", actions),\n        ),\n      );\n    }\n  }\n\n  return details;\n}\n\nfunction renderNotifications(notifs): Child[] {\n  const notificationElements: Child[] = [];\n\n  for (const n of notifs) {\n    let notifColors = \"\",\n      buttonColors = \"\";\n    if (n.type === \"success\") {\n      notifColors = \"bg-emerald-50 text-emerald-800 border-emerald-100\";\n      buttonColors =\n        \"hover:bg-emerald-100 text-emerald-800 focus:ring-offset-emerald-50 focus:ring-emerald-600\";\n    } else if (n.type === \"error\") {\n      notifColors = \"bg-red-50 text-red-800 border-red-100\";\n      buttonColors =\n        \"hover:bg-red-100 text-red-800 focus:ring-offset-red-50 focus:ring-red-600\";\n    } else if (n.type === \"warning\") {\n      notifColors = \"bg-yellow-50 text-yellow-800 border-yellow-100\";\n      buttonColors =\n        \"hover:bg-yellow-100 text-yellow-800 focus:ring-offset-yellow-50 focus:ring-yellow-600\";\n    }\n\n    let buttons;\n    if (n.actions) {\n      const btns = Object.entries(n.actions).map(([label, onclick]) =>\n        m(\n          \"button\",\n          {\n            class:\n              \"ml-2 px-2 py-1.5 -my-1.5 rounded-md text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 \" +\n              buttonColors,\n            onclick: onclick,\n          },\n          label,\n        ),\n      );\n      if (btns.length) buttons = m(\"div\", btns);\n    }\n\n    notificationElements.push(\n      m(\n        \"div\",\n        {\n          class:\n            \"absolute flex justify-between rounded-md w-full text-sm shadow-md p-4 border transition-[top,opacity] \" +\n            notifColors,\n          style: \"opacity: 0\",\n          oncreate: (vnode) => {\n            (vnode.dom as HTMLDivElement).style.opacity = \"1\";\n          },\n          onbeforeremove: (vnode) => {\n            (vnode.dom as HTMLDivElement).style.opacity = \"0\";\n            return new Promise<void>((resolve) => {\n              setTimeout(() => {\n                resolve();\n              }, 500);\n            });\n          },\n          key: n.timestamp,\n        },\n        n.message,\n        buttons,\n      ),\n    );\n  }\n  return notificationElements;\n}\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      const queue = getQueue();\n      const staging = getStaging();\n      const notifs = notifications.getNotifications();\n\n      let drawerElement, statusElement;\n      const notificationElements = renderNotifications(notifs);\n      const stagingElements = renderStaging(staging);\n      const queueElements = renderQueue(queue);\n\n      function repositionNotifications(): void {\n        let top = 16;\n        for (const c of notificationElements as VnodeDOM[]) {\n          (c.dom as HTMLDivElement).style.top = `${top}px`;\n          top += (c.dom as HTMLDivElement).offsetHeight + 16;\n        }\n      }\n\n      function resizeDrawer(): void {\n        let height =\n          statusElement.dom.offsetTop + statusElement.dom.offsetHeight;\n        if (stagingElements.length) {\n          for (const s of stagingElements as VnodeDOM[]) {\n            height = Math.max(\n              height,\n              (s.dom as HTMLDivElement).offsetTop +\n                (s.dom as HTMLDivElement).offsetHeight,\n            );\n          }\n        } else if (vnode.state[\"mouseIn\"]) {\n          for (const c of drawerElement.children)\n            height = Math.max(height, c.dom.offsetTop + c.dom.offsetHeight);\n        }\n        drawerElement.dom.style.height = height + \"px\";\n      }\n\n      if (stagingElements.length + queueElements.length) {\n        const statusCount = { queued: 0, pending: 0, fault: 0, stale: 0 };\n        for (const t of queue) statusCount[t[\"status\"]] += 1;\n\n        const actions = m(\n          \"div.flex ml-auto gap-2\",\n          m(\n            \"button\",\n            {\n              class:\n                \"px-2.5 py-1.5 -my-1.5 border border-stone-300 shadow-xs text-xs font-medium rounded-sm text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              title: \"Clear tasks\",\n              onclick: clear,\n              disabled: !queueElements.length,\n            },\n            \"Clear\",\n          ),\n          m(\n            \"button\",\n            {\n              class:\n                \"px-2.5 py-1.5 -my-1.5 border border-transparent text-xs font-medium rounded-sm shadow-xs text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              title: \"Commit queued tasks\",\n              disabled: !statusCount.queued,\n              onclick: () => {\n                const tasks = Array.from(getQueue()).filter(\n                  (t) => t[\"status\"] === \"queued\",\n                );\n                commit(\n                  tasks,\n                  (deviceId, err, connectionRequestStatus, tasks2) => {\n                    if (err) {\n                      notifications.push(\n                        \"error\",\n                        `${deviceId}: ${err.message}`,\n                      );\n                      return;\n                    }\n\n                    if (connectionRequestStatus !== \"OK\") {\n                      notifications.push(\n                        \"error\",\n                        `${deviceId}: ${connectionRequestStatus}`,\n                      );\n                      return;\n                    }\n\n                    for (const t of tasks2) {\n                      if (t.status === \"stale\") {\n                        notifications.push(\n                          \"error\",\n                          `${deviceId}: No contact from device`,\n                        );\n                        return;\n                      } else if (t.status === \"fault\") {\n                        notifications.push(\n                          \"error\",\n                          `${deviceId}: Task(s) faulted`,\n                        );\n                        return;\n                      }\n                    }\n\n                    notifications.push(\n                      \"success\",\n                      `${deviceId}: Task(s) committed`,\n                    );\n                  },\n                )\n                  .then(() => {\n                    store.setTimestamp(Date.now());\n                    invalidate(Date.now());\n                  })\n                  .catch((err) => {\n                    notifications.push(\"error\", err.message);\n                  });\n              },\n            },\n            \"Commit\",\n          ),\n        );\n\n        statusElement = m(\n          \"div.flex p-4 gap-5 items-center text-sm\",\n          m(\n            \"span.text-stone-700 -mx-1 px-1\",\n            { class: statusCount.queued ? \"font-semibold\" : \"\" },\n            `Queued: ${statusCount.queued}`,\n          ),\n          m(\n            \"span.text-stone-700 relative\",\n            statusCount.pending\n              ? m(\n                  \"div.block absolute -inset-x-1 inset-y-0 rounded-sm bg-emerald-200 animate-pulse\",\n                  \"\",\n                )\n              : null,\n            m(\"span.relative\", `Pending: ${statusCount.pending}`),\n          ),\n          m(\n            \"span.text-stone-700 relative\",\n            m(\"span.relative\", `Fault: ${statusCount.fault}`),\n          ),\n          m(\n            \"span.text-stone-700 relative\",\n            statusCount.stale\n              ? m(\n                  \"div.block absolute -inset-x-1 inset-y-0 rounded-sm bg-stone-200\",\n                  \"\",\n                )\n              : null,\n            m(\"span.relative\", `Stale: ${statusCount.stale}`),\n          ),\n          actions,\n        );\n\n        drawerElement = m(\n          \"div\",\n          {\n            class:\n              \"w-[48rem] mx-auto pointer-events-auto bg-white rounded-b-lg border-stone-300 border-x border-b shadow-md overflow-hidden transition-[height] -mt-px\",\n            key: \"drawer\",\n            style: \"opacity: 0;height: 0;\",\n            oncreate: (vnode2) => {\n              vnode.state[\"mouseIn\"] = false;\n              (vnode2.dom as HTMLDivElement).style.opacity = \"1\";\n              resizeDrawer();\n            },\n            onmouseenter: (e) => {\n              if (drawerElement.dom.style.opacity === \"0\") return;\n              vnode.state[\"mouseIn\"] = true;\n              resizeDrawer();\n              e.redraw = false;\n            },\n            onmouseleave: (e) => {\n              if (drawerElement.dom.style.opacity === \"0\") return;\n              vnode.state[\"mouseIn\"] = false;\n              resizeDrawer();\n              e.redraw = false;\n            },\n            onupdate: resizeDrawer,\n            onbeforeremove: (vnode2) => {\n              (vnode2.dom as HTMLDivElement).style.opacity = \"0\";\n              (vnode2.dom as HTMLDivElement).style.height = \"0\";\n              return new Promise((resolve) => {\n                setTimeout(resolve, 500);\n              });\n            },\n          },\n          statusElement,\n          stagingElements.length\n            ? stagingElements\n            : m(\"div.px-4 pb-4 text-sm\", queueElements),\n        );\n      }\n\n      return m(\n        \"div.fixed pointer-events-none inset-0 z-30\",\n        drawerElement,\n        m(\n          \"div\",\n          {\n            class: \"relative w-[48rem] mx-auto pointer-events-auto\",\n            key: \"notifications\",\n            onupdate: repositionNotifications,\n            oncreate: repositionNotifications,\n          },\n          notificationElements,\n        ),\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/dynamic-loader.ts",
    "content": "import * as notifications from \"./notifications.ts\";\n\nexport let codeMirror: typeof import(\"./codemirror-loader\");\nexport let yaml: typeof import(\"./yaml-loader\");\n\nlet note;\n\nfunction onError(): void {\n  if (!note) {\n    note = notifications.push(\n      \"error\",\n      \"Error loading JS resource, please reload the page\",\n      {\n        Reload: () => {\n          window.location.reload();\n        },\n      },\n    );\n  }\n}\n\nexport function loadCodeMirror(): Promise<void> {\n  if (codeMirror) return Promise.resolve();\n\n  return new Promise((resolve, reject) => {\n    import(\"./codemirror-loader\")\n      .then((module) => {\n        codeMirror = module;\n        resolve();\n      })\n      .catch((err) => {\n        onError();\n        reject(err);\n      });\n  });\n}\n\nexport function loadYaml(): Promise<void> {\n  if (yaml) return Promise.resolve();\n\n  return new Promise((resolve, reject) => {\n    import(\"./yaml-loader\")\n      .then((module) => {\n        yaml = module;\n        resolve();\n      })\n      .catch((err) => {\n        onError();\n        reject(err);\n      });\n  });\n}\n"
  },
  {
    "path": "ui/error-page.ts",
    "content": "import { ClosureComponent, Component } from \"mithril\";\nimport { m } from \"./components.ts\";\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: function (vnode) {\n      document.title = \"Error! - GenieACS\";\n      return m(\"p.text-sm font-bold text-red-500\", vnode.attrs[\"error\"]);\n    },\n  };\n};\n"
  },
  {
    "path": "ui/faults-page.ts",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE } from \"./config.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport { stringify as yamlStringify } from \"../lib/common/yaml.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes = [\n  { id: \"device\", label: \"Device\" },\n  { id: \"channel\", label: \"Channel\" },\n  { id: \"code\", label: \"Code\" },\n  { id: \"message\", label: \"Message\" },\n  { id: \"detail\", label: \"Detail\" },\n  { id: \"retries\", label: \"Retries\" },\n  { id: \"timestamp\", label: \"Timestamp\" },\n];\n\nconst getDownloadUrl = memoize((filter) => {\n  const cols = {};\n  for (const attr of attributes) {\n    cols[attr.label] =\n      attr.id === \"timestamp\" ? `DATE_STRING(${attr.id})` : attr.id;\n  }\n\n  return `api/faults.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"faults\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\nasync function deleteFaults(faults: Iterable<string>): Promise<void> {\n  const proms: Map<string, Promise<void>> = new Map();\n  for (const f of faults) {\n    const deviceId = f.split(\":\", 1)[0];\n    let p = proms.get(deviceId);\n    if (p == null) p = store.deleteResource(\"faults\", f);\n    else p = p.then(() => store.deleteResource(\"faults\", f));\n    proms.set(deviceId, p);\n  }\n  await Promise.all(proms.values());\n}\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"faults\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n  let filter: Expression = null;\n  let sort: Record<string, number> = null;\n  if (args.hasOwnProperty(\"filter\"))\n    filter = Expression.parse(args[\"filter\"] as string);\n  if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n  return Promise.resolve({ filter, sort });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Faults - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/faults\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++) {\n        const attr = attributes[i];\n        if (attr.id !== \"detail\") sortAttributes[i] = sort[attr.id] || 0;\n      }\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/faults\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const faults = store.fetch(\"faults\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n      const count = store.count(\"faults\", filter);\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const valueCallback = (attr, fault): Children => {\n        if (attr.id === \"device\") {\n          const deviceHref = `#!/devices/${encodeURIComponent(\n            fault[\"device\"],\n          )}`;\n\n          return m(\n            \"a.text-cyan-700 hover:text-cyan-900 font-medium\",\n            { href: deviceHref },\n            fault[\"device\"],\n          );\n        }\n\n        if (attr.id === \"message\")\n          return m(\"long-text\", { text: fault[\"message\"], class: \"max-w-xs\" });\n\n        if (attr.id === \"detail\") {\n          return m(\"long-text\", {\n            text: yamlStringify(fault[\"detail\"]),\n            class: \"max-w-xs\",\n          });\n        }\n\n        if (attr.id === \"timestamp\")\n          return new Date(fault[\"timestamp\"]).toLocaleString();\n\n        return fault[attr.id];\n      };\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = faults.value;\n      attrs[\"valueCallback\"] = valueCallback;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n\n      if (window.authorizer.hasAccess(\"faults\", 3)) {\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return m(\n            \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n            {\n              disabled: selected.size === 0,\n              title: \"Delete selected faults\",\n              onclick: (e) => {\n                e.redraw = false;\n                e.target.disabled = true;\n\n                if (!confirm(`Deleting ${selected.size} faults. Are you sure?`))\n                  return;\n\n                const c = selected.size;\n                deleteFaults(selected)\n                  .then(() => {\n                    notifications.push(\"success\", `${c} faults deleted`);\n                    store.setTimestamp(Date.now());\n                  })\n                  .catch((err) => {\n                    notifications.push(\"error\", err.message);\n                    store.setTimestamp(Date.now());\n                  });\n              },\n            },\n            \"Delete\",\n          );\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"faults\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing faults\"),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [faults, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/files-page.ts",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE } from \"./config.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes: {\n  id: string;\n  label: string;\n  type?: string;\n  options?: any;\n}[] = [\n  { id: \"_id\", label: \"Name\" },\n  {\n    id: \"metadata.fileType\",\n    label: \"Type\",\n    options: [\n      \"1 Firmware Upgrade Image\",\n      \"2 Web Content\",\n      \"3 Vendor Configuration File\",\n      \"4 Tone File\",\n      \"5 Ringer File\",\n    ],\n  },\n  { id: \"metadata.oui\", label: \"OUI\" },\n  { id: \"metadata.productClass\", label: \"Product Class\" },\n  { id: \"metadata.version\", label: \"Version\" },\n];\n\nconst formData = {\n  resource: \"files\",\n  attributes: attributes\n    .slice(1) // remove _id from new object form\n    .concat([{ id: \"file\", label: \"File\", type: \"file\" }]),\n};\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"files\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\nfunction upload(\n  file: File,\n  headers: Record<string, string>,\n  abortSignal?: AbortSignal,\n  progressListener?: (e: ProgressEvent) => void,\n): Promise<void> {\n  headers = Object.assign(\n    {\n      \"Content-Type\": \"application/octet-stream\",\n    },\n    headers,\n  );\n  return store.xhrRequest({\n    method: \"PUT\",\n    headers: headers,\n    url: `api/files/${encodeURIComponent(file.name)}`,\n    serialize: (body) => body, // Identity function to prevent JSON.parse on blob data\n    body: file,\n    config: (xhr) => {\n      if (progressListener)\n        xhr.upload.addEventListener(\"progress\", progressListener);\n      if (abortSignal) {\n        if (abortSignal.aborted) xhr.abort();\n        abortSignal.addEventListener(\"abort\", () => xhr.abort());\n      }\n    },\n  });\n}\n\nconst getDownloadUrl = memoize((filter) => {\n  const cols = {};\n  for (const attr of attributes) cols[attr.label] = attr.id;\n  return `api/files.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"files\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  let filter: Expression = null;\n  let sort: Record<string, number> = null;\n  if (args.hasOwnProperty(\"filter\"))\n    filter = Expression.parse(args[\"filter\"] as string);\n  if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n  return Promise.resolve({ filter, sort });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Files - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/files\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++)\n        sortAttributes[i] = sort[attributes[i].id] || 0;\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/files\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const files = store.fetch(\"files\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n\n      const count = store.count(\"files\", filter);\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = files.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n      attrs[\"recordActionsCallback\"] = (file) => {\n        return [\n          m(\n            \"a.text-cyan-700 hover:text-cyan-900\",\n            { href: \"api/blob/files/\" + file[\"_id\"] },\n            \"Download\",\n          ),\n        ];\n      };\n\n      if (window.authorizer.hasAccess(\"files\", 3)) {\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return [\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Create new file\",\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const abortController = new AbortController();\n                  let progress = -1;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        actionHandler: async (action, obj) => {\n                          if (action !== \"save\")\n                            throw new Error(\"Undefined action\");\n                          const file = obj[\"file\"]?.[0];\n\n                          // nginx strips out headers with dot, so replace with dash\n                          const headers = {\n                            \"metadata-fileType\": obj[\"metadata.fileType\"] || \"\",\n                            \"metadata-oui\": obj[\"metadata.oui\"] || \"\",\n                            \"metadata-productclass\":\n                              obj[\"metadata.productClass\"] || \"\",\n                            \"metadata-version\": obj[\"metadata.version\"] || \"\",\n                          };\n\n                          if (!file) {\n                            notifications.push(\"error\", \"File not selected\");\n                            return;\n                          }\n\n                          if (await store.resourceExists(\"files\", file.name)) {\n                            store.setTimestamp(Date.now());\n                            notifications.push(\"error\", \"File already exists\");\n                            return;\n                          }\n\n                          const progressListener = (e: ProgressEvent): void => {\n                            progress = e.loaded / e.total;\n                            m.redraw();\n                          };\n\n                          progress = 0;\n                          try {\n                            await upload(\n                              file,\n                              headers,\n                              abortController.signal,\n                              progressListener,\n                            );\n                            store.setTimestamp(Date.now());\n                            notifications.push(\"success\", \"File created\");\n                            overlay.close(cb);\n                          } catch (err) {\n                            notifications.push(\"error\", err.message);\n                          }\n                          progress = -1;\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => {\n                    if (progress < 0) return [null, comp];\n                    return [\n                      m(\n                        \"div.progress\",\n                        m(\"div.progress-bar\", {\n                          style: `width: ${Math.trunc(progress * 100)}%`,\n                        }),\n                      ),\n                      comp,\n                    ];\n                  };\n                  overlay.open(cb, () => {\n                    if (\n                      comp.state[\"current\"][\"modified\"] &&\n                      !confirm(\"You have unsaved changes. Close anyway?\")\n                    )\n                      return false;\n                    abortController.abort();\n                    return true;\n                  });\n                },\n              },\n              \"New\",\n            ),\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Delete selected files\",\n                disabled: !selected.size,\n                onclick: (e) => {\n                  if (\n                    !confirm(`Deleting ${selected.size} files. Are you sure?`)\n                  )\n                    return;\n\n                  e.redraw = false;\n                  e.target.disabled = true;\n                  Promise.all(\n                    Array.from(selected).map((id) =>\n                      store.deleteResource(\"files\", id),\n                    ),\n                  )\n                    .then((res) => {\n                      notifications.push(\n                        \"success\",\n                        `${res.length} files deleted`,\n                      );\n                      store.setTimestamp(Date.now());\n                    })\n                    .catch((err) => {\n                      notifications.push(\"error\", err.message);\n                      store.setTimestamp(Date.now());\n                    });\n                },\n              },\n              \"Delete\",\n            ),\n          ];\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"files\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing files\"),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [files, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/filter-component.ts",
    "content": "import m, { ClosureComponent } from \"mithril\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport Autocomplete from \"./autocomplete-compnent.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport { validQuery } from \"../lib/db/synth.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nconst getAutocomplete = memoize((resource) => {\n  const labels = smartQuery.getLabels(resource);\n  const autocomplete = new Autocomplete((txt, cb) => {\n    txt = txt.toLowerCase();\n    cb(\n      labels\n        .filter((s) => s.toLowerCase().includes(txt))\n        .map((s) => ({\n          value: `${s}: `,\n          tip: smartQuery.getTip(resource, s),\n        })),\n    );\n  });\n  return autocomplete;\n});\n\nfunction parseFilter(resource: string, f: string): Expression {\n  let exp;\n  if (/^[\\s0-9a-zA-Z]+:/.test(f)) {\n    const k = f.split(\":\", 1)[0];\n    const v = f.slice(k.length + 1).trim();\n    exp = new Expression.FunctionCall(\"Q\", [\n      new Expression.Literal(k.trim()),\n      new Expression.Literal(v),\n    ]);\n  } else {\n    exp = Expression.parse(f);\n  }\n\n  const unpacked = exp.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"NOW\") return new Expression.Literal(Date.now());\n      else if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          const r = smartQuery.unpack(\n            resource,\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n          return r;\n        }\n      }\n    }\n    return e;\n  });\n\n  // Throws exception if invalid Mongo query\n  validQuery(unpacked, resource);\n\n  return exp;\n}\n\nfunction splitFilter(filter: Expression): string[] {\n  if (!filter) return [\"\"];\n  if (filter instanceof Expression.Literal && filter.value) return [\"\"];\n  const list: Expression[] = [filter];\n  const res: string[] = [];\n  while (list.length) {\n    const f = list.pop();\n    if (f instanceof Expression.Binary && f.operator === \"AND\") {\n      list.push(f.right);\n      list.push(f.left);\n    } else if (f instanceof Expression.FunctionCall && f.name === \"Q\") {\n      const l = f.args[0] as Expression.Literal;\n      const r = f.args[1] as Expression.Literal;\n      res.push(`${l.value}: ${r.value}`);\n    } else {\n      res.push(f.toString());\n    }\n  }\n\n  res.push(\"\");\n  return res;\n}\n\ninterface Attrs {\n  resource: string;\n  filter?: Expression;\n  onChange: (filter: Expression) => void;\n}\n\nconst component: ClosureComponent<Attrs> = (initialVnode) => {\n  let filterList = splitFilter(initialVnode.attrs.filter);\n  let filterInvalid = 0;\n  let filterTouched = false;\n  let attrs: Attrs = initialVnode.attrs;\n\n  function onChange(): void {\n    filterTouched = false;\n    filterInvalid = 0;\n    filterList = filterList.filter((f) => f);\n    let filter: Expression = new Expression.Literal(true);\n    for (const [idx, f] of filterList.entries()) {\n      try {\n        filter = Expression.and(filter, parseFilter(attrs.resource, f));\n      } catch {\n        filterInvalid |= 1 << idx;\n      }\n    }\n    filterList.push(\"\");\n\n    if (filterInvalid) {\n      m.redraw();\n      return;\n    }\n    if (!filterList.length) attrs.onChange(null);\n    else attrs.onChange(filter);\n  }\n\n  return {\n    onupdate: (vnode) => {\n      getAutocomplete(vnode.attrs.resource).reposition();\n    },\n    view: (vnode) => {\n      if (attrs.filter !== vnode.attrs.filter) {\n        filterInvalid = 0;\n        filterList = splitFilter(vnode.attrs.filter);\n      }\n\n      attrs = vnode.attrs;\n\n      return m(\"div.mb-5\", [\n        m(\"label.text-sm font-semibold text-stone-700\", \"Filter\"),\n        m(\n          \"div.shadow-sm rounded-md mt-1 max-w-screen-sm -space-y-px\",\n          ...filterList.map((fltr, idx) => {\n            let classNames =\n              \"appearance-none rounded-none relative block w-full px-3 py-2 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm\";\n            if (idx === 0) classNames += \" rounded-t-md\";\n            if (idx === filterList.length - 1) classNames += \" rounded-b-md\";\n            if (filterInvalid & (1 << idx)) classNames += \" !text-red-700\";\n\n            return m(`input`, {\n              type: \"text\",\n              class: classNames,\n              value: fltr,\n              oninput: (e) => {\n                e.redraw = false;\n                filterList[idx] = e.target.value;\n                filterTouched = true;\n              },\n              oncreate: (vn) => {\n                const el = vn.dom as HTMLInputElement;\n                getAutocomplete(vnode.attrs.resource).attach(el);\n\n                el.addEventListener(\"blur\", () => {\n                  if (filterTouched) onChange();\n                });\n\n                el.addEventListener(\"keydown\", (e) => {\n                  if (e.key === \"Enter\" && filterTouched) onChange();\n                });\n              },\n            });\n          }),\n        ),\n      ]);\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/index-table-component.ts",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { icon } from \"./tailwind-utility-components.ts\";\nimport debounce from \"../lib/common/debounce.ts\";\n\ninterface Attribute {\n  id?: string;\n  label: string;\n  type?: string;\n}\n\nconst MAX_PAGE_SIZE = 200;\n\nfunction getExcerpt(text: string, maxLength = 80, maxLines = 10): string[] {\n  let lines: string[] = text?.split(\"\\n\", maxLines + 1) ?? [\"\"];\n\n  if (lines.length > maxLines) {\n    lines.pop();\n    lines[maxLines - 1] = \"\\ufe19\";\n  }\n\n  lines = lines.map((l) => {\n    if (l.length <= maxLength) return l;\n    return l.slice(0, maxLength - 1) + \"\\u2026\";\n  });\n\n  return lines;\n}\n\nfunction renderTable(\n  attributes: Attribute[],\n  data: Record<string, any>[],\n  total: number,\n  showMoreCallback: () => void,\n  selected: Set<string>,\n  sortAttributes: Record<string, any>,\n  onSort: (i: number) => void,\n  downloadUrl?: string,\n  valueCallback?: (attr: Attribute, record: Record<string, any>) => Children,\n  actionsCallback?: Children | ((sel: Set<string>) => Children),\n  recordActionsCallback?:\n    | Children\n    | ((record: Record<string, any>) => Children),\n): Children {\n  const records = data || [];\n\n  // Actions bar\n  let buttons: Children = [];\n  if (typeof actionsCallback === \"function\") {\n    buttons = actionsCallback(selected);\n    if (!Array.isArray(buttons)) buttons = [buttons];\n  } else if (Array.isArray(actionsCallback)) {\n    buttons = actionsCallback;\n  }\n\n  // Table header\n  const labels = [];\n  if (buttons.length) {\n    const selectAll = m(\n      \"input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm\",\n      {\n        type: \"checkbox\",\n        checked: records.length && selected.size === records.length,\n        onchange: (e) => {\n          for (const record of records) {\n            const id = record[\"_id\"] ?? record[\"DeviceID.ID\"];\n            if (e.target.checked) selected.add(id);\n            else selected.delete(id);\n          }\n        },\n        disabled: !total,\n      },\n    );\n    labels.push(\n      m(\n        \"th\",\n        { class: \"px-6 py-3.5 w-0\", scope: \"col\" },\n        m(\"span.sr-only\", \"Select\"),\n        selectAll,\n      ),\n    );\n  }\n\n  for (const [i, attr] of attributes.entries()) {\n    let padding: string;\n    if (i === 0) padding = buttons.length ? \"pr-3\" : \"pl-6 pr-3\";\n    else if (i === attributes.length - +!recordActionsCallback)\n      padding = \"pl-3 pr-6\";\n    else padding = \"px-3\";\n\n    const label = attr.label;\n    if (!sortAttributes.hasOwnProperty(i)) {\n      labels.push(\n        m(\n          \"th\",\n          {\n            class:\n              \"py-3.5 text-left text-sm font-semibold text-stone-500 \" +\n              padding,\n            scope: \"col\",\n          },\n          label,\n        ),\n      );\n      continue;\n    }\n\n    let symbol: Children;\n    if (sortAttributes[i] > 0) {\n      symbol = m(icon, { name: \"sorted-asc\", class: \"inline h-4 w-4 ml-1\" });\n    } else if (sortAttributes[i] < 0) {\n      symbol = m(icon, { name: \"sorted-dsc\", class: \"inline h-4 w-4 ml-1\" });\n    } else {\n      symbol = m(icon, {\n        name: \"unsorted\",\n        class: \"inline h-4 w-4 ml-1 opacity-50 hover:opacity-100\",\n      });\n    }\n\n    const sortable = m(\n      \"button\",\n      {\n        onclick: (e) => {\n          e.redraw = false;\n          onSort(i);\n        },\n      },\n      symbol,\n    );\n\n    labels.push(\n      m(\n        \"th\",\n        {\n          class:\n            \"py-3.5 text-left text-sm font-semibold text-stone-500 whitespace-nowrap \" +\n            padding,\n          scope: \"col\",\n        },\n        [label, sortable],\n      ),\n    );\n  }\n\n  if (recordActionsCallback)\n    labels.push(m(\"th\", { class: \"pl-3 pr-6 py-3.5 w-0\", scope: \"col\" }));\n\n  // Table rows\n  const rows = [];\n  for (const record of records) {\n    const id = record[\"_id\"] ?? record[\"DeviceID.ID\"];\n    const tds = [];\n    const isSelected = selected.has(id);\n    if (buttons.length) {\n      const checkbox = m(\n        \"input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm\",\n        {\n          type: \"checkbox\",\n          checked: isSelected,\n          onchange: (e) => {\n            if (e.target.checked) selected.add(id);\n            else selected.delete(id);\n          },\n          onclick: (e) => {\n            e.stopPropagation();\n            e.redraw = false;\n          },\n        },\n      );\n      tds.push(\n        m(\"td.px-6 py-4 whitespace-nowrap text-sm text-stone-500\", checkbox),\n      );\n    }\n\n    for (const [i, attr] of attributes.entries()) {\n      let padding: string;\n      if (i === 0) padding = buttons.length ? \"pr-3\" : \"pl-6 pr-3\";\n      else if (i === attributes.length - +!recordActionsCallback)\n        padding = \"pl-3 pr-6\";\n      else padding = \"px-3\";\n\n      const attrs = {\n        class: \"py-4 whitespace-nowrap text-sm text-stone-900 \" + padding,\n      };\n      let valueComponent;\n\n      if (typeof valueCallback === \"function\") {\n        valueComponent = valueCallback(attr, record);\n      } else if (attr.type === \"code\") {\n        const excerpt = getExcerpt(record[attr.id]);\n        valueComponent = m(\n          \"span.font-mono\",\n          { title: excerpt.join(\"\\n\") },\n          excerpt[0],\n        );\n      } else {\n        valueComponent = record[attr.id];\n      }\n      // TODO automatically add long text component on long values\n\n      tds.push(m(\"td\", attrs, valueComponent));\n    }\n\n    let recordButtons: Children = [];\n    if (typeof recordActionsCallback === \"function\") {\n      recordButtons = recordActionsCallback(record);\n      if (!Array.isArray(recordButtons)) recordButtons = [recordButtons];\n    } else if (Array.isArray(recordActionsCallback)) {\n      recordButtons = recordActionsCallback;\n    }\n\n    for (const button of recordButtons) {\n      tds.push(\n        m(\n          \"td.pl-3 pr-6 py-4 whitespace-nowrap text-right text-sm font-medium\",\n          button,\n        ),\n      );\n    }\n\n    rows.push(\n      m(\n        \"tr\",\n        {\n          class: isSelected ? \"bg-stone-50\" : \"\",\n          onclick: (e) => {\n            if (e.target.closest(\"input, button, a\")) {\n              e.redraw = false;\n              return;\n            }\n\n            if (!selected.delete(id)) selected.add(id);\n          },\n        },\n        tds,\n      ),\n    );\n  }\n\n  if (!rows.length) {\n    rows.push(\n      m(\n        \"tr\",\n        m(\n          \"td.bg-stripes text-sm font-medium text-center text-stone-500 p-4\",\n          { colspan: labels.length },\n          \"No records\",\n        ),\n      ),\n    );\n  }\n\n  // Table footer\n  const pagination = [];\n  if (total != null) pagination.push(`${records.length} / ${total}`);\n  else pagination.push(`${records.length}`);\n\n  pagination.push(\n    m(\n      \"button.px-4 py-2 border border-stone-300 rounded-md text-stone-700 bg-white hover:bg-stone-50 ml-4 disabled:opacity-50 disabled:cursor-not-allowed\",\n      {\n        title: \"Show more records\",\n        onclick: showMoreCallback,\n        disabled:\n          !data.length || records.length >= Math.min(MAX_PAGE_SIZE, total),\n      },\n      \"More\",\n    ),\n  );\n\n  let download: Children;\n  if (downloadUrl) {\n    download = m(\n      \"a.text-cyan-700 hover:text-cyan-900\",\n      { href: downloadUrl, download: \"\" },\n      \"Download\",\n    );\n  }\n\n  const tfoot = m(\n    \"tfoot.bg-white\",\n    m(\n      \"tr\",\n      m(\n        \"td.px-6 py-3 text-sm font-medium text-stone-700\",\n        { colspan: labels.length },\n        m(\n          \"div.flex items-center justify-between\",\n          m(\"div\", pagination),\n          download,\n        ),\n      ),\n    ),\n  );\n\n  const children = [\n    m(\n      \"div.flex flex-col\",\n      m(\n        \"div.-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8\",\n        m(\n          \"div.py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8\",\n          m(\n            \"div.shadow-sm overflow-hidden border-b border-stone-200 sm:rounded-lg\",\n            m(\n              \"table.min-w-full divide-y divide-stone-200\",\n              m(\"thead.bg-stone-50\", m(\"tr\", labels)),\n              m(\"tbody.bg-white divide-y divide-stone-200\", rows),\n              tfoot,\n            ),\n          ),\n        ),\n      ),\n    ),\n  ];\n\n  if (buttons.length) children.push(m(\"div.flex gap-3 mt-4\", buttons));\n  return children;\n}\n\nconst component: ClosureComponent = (): Component => {\n  let selected = new Set<string>();\n  let sortingfunction: (events: number[]) => void;\n  const onSort = debounce((events: number[]) => {\n    sortingfunction(events);\n  }, 500);\n  return {\n    view: (vnode) => {\n      const attributes = vnode.attrs[\"attributes\"];\n      const data = vnode.attrs[\"data\"];\n      const valueCallback = vnode.attrs[\"valueCallback\"];\n      const total = vnode.attrs[\"total\"];\n      const showMoreCallback = vnode.attrs[\"showMoreCallback\"];\n      const sortAttributes = vnode.attrs[\"sortAttributes\"];\n      const onSortChange = vnode.attrs[\"onSortChange\"];\n      const downloadUrl = vnode.attrs[\"downloadUrl\"];\n      const actionsCallback = vnode.attrs[\"actionsCallback\"];\n      const recordActionsCallback = vnode.attrs[\"recordActionsCallback\"];\n\n      const _selected = new Set<string>();\n      for (const record of data) {\n        const id = record[\"_id\"] ?? record[\"DeviceID.ID\"];\n        if (selected.has(id)) _selected.add(id);\n      }\n\n      sortingfunction = (events) => {\n        const sortArray = new Set(\n          Object.keys(sortAttributes)\n            .map((x) => (parseInt(x) + 1) * sortAttributes[x])\n            .filter((x) => x),\n        );\n        for (const num of events) {\n          if (sortArray.delete(num + 1)) sortArray.add(-(num + 1));\n          else if (!sortArray.delete((num + 1) * -1)) sortArray.add(num + 1);\n        }\n        onSortChange(Array.from(sortArray).reverse());\n      };\n\n      selected = _selected;\n\n      return renderTable(\n        attributes,\n        data,\n        total,\n        showMoreCallback,\n        selected,\n        sortAttributes,\n        onSort,\n        downloadUrl,\n        valueCallback,\n        actionsCallback,\n        recordActionsCallback,\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/layout.tsx",
    "content": "import m, { ClosureComponent } from \"mithril\";\nimport drawerComponent from \"./drawer-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport { version as VERSION } from \"../package.json\";\nimport datalist from \"./datalist.ts\";\nimport {\n  transitionRoot,\n  transitionChild,\n  dialog,\n  dialogOverlay,\n  icon,\n} from \"./tailwind-utility-components.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport { LOGO_SVG } from \"../build/assets.ts\";\n\nfunction tsxComponent<T>(\n  c: ClosureComponent<T>,\n): (attrs: T) => ReturnType<typeof m> {\n  return c as any;\n}\n\nconst TransitionRoot = tsxComponent(transitionRoot);\nconst TransitionChild = tsxComponent(transitionChild);\nconst Dialog = tsxComponent(dialog);\nconst DialogOverlay = tsxComponent(dialogOverlay);\nconst Icon = tsxComponent(icon);\n\nfunction classNames(...classes: string[]): string {\n  return classes.filter(Boolean).join(\" \");\n}\n\ninterface Attrs {\n  page: string;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  let sidebarOpen = false;\n\n  function setSidebarOpen(open: boolean): void {\n    sidebarOpen = open;\n    setTimeout(m.redraw);\n  }\n\n  return {\n    view: (vnode) => {\n      const navigation = [\n        {\n          name: \"Overview\",\n          href: \"#!/overview\",\n          enabled: window.authorizer.hasAccess(\"devices\", 1),\n        },\n        {\n          name: \"Devices\",\n          href: \"#!/devices\",\n          enabled: window.authorizer.hasAccess(\"devices\", 2),\n        },\n        {\n          name: \"Faults\",\n          href: \"#!/faults\",\n          enabled: window.authorizer.hasAccess(\"faults\", 2),\n        },\n        {\n          name: \"Presets\",\n          href: \"#!/presets\",\n          enabled: window.authorizer.hasAccess(\"presets\", 2),\n        },\n        {\n          name: \"Provisions\",\n          href: \"#!/provisions\",\n          enabled: window.authorizer.hasAccess(\"provisions\", 2),\n        },\n        {\n          name: \"Virtual Parameters\",\n          href: \"#!/virtualParameters\",\n          enabled: window.authorizer.hasAccess(\"virtualParameters\", 2),\n        },\n        {\n          name: \"Files\",\n          href: \"#!/files\",\n          enabled: window.authorizer.hasAccess(\"files\", 2),\n        },\n        {\n          name: \"Config\",\n          href: \"#!/config\",\n          enabled: window.authorizer.hasAccess(\"config\", 2),\n        },\n        {\n          name: \"Permissions\",\n          href: \"#!/permissions\",\n          enabled: window.authorizer.hasAccess(\"permissions\", 2),\n        },\n        {\n          name: \"Users\",\n          href: \"#!/users\",\n          enabled: window.authorizer.hasAccess(\"users\", 2),\n        },\n        {\n          name: \"Views\",\n          href: \"#!/views\",\n          enabled: window.authorizer.hasAccess(\"views\", 2),\n        },\n      ]\n        .filter((item) => item.enabled)\n        .map(({ name, href }) => {\n          const n = href.slice(3);\n          return { name, href, active: vnode.attrs[\"page\"] === n };\n        });\n\n      return [\n        <div>\n          <TransitionRoot show={!!sidebarOpen} duration={300}>\n            <Dialog\n              as=\"div\"\n              class=\"fixed inset-0 flex z-40 md:hidden\"\n              onClose={() => setSidebarOpen(false)}\n            >\n              <TransitionChild\n                enter=\"transition-opacity ease-linear duration-300\"\n                enterFrom=\"opacity-0\"\n                enterTo=\"opacity-100\"\n                leave=\"transition-opacity ease-linear duration-300\"\n                leaveFrom=\"opacity-100\"\n                leaveTo=\"opacity-0\"\n              >\n                <DialogOverlay class=\"fixed inset-0 bg-black/50\" />\n              </TransitionChild>\n              <TransitionChild\n                enter=\"transition ease-in-out duration-300 transform\"\n                enterFrom=\"-translate-x-full\"\n                enterTo=\"translate-x-0\"\n                leave=\"transition ease-in-out duration-300 transform\"\n                leaveFrom=\"translate-x-0\"\n                leaveTo=\"-translate-x-full\"\n              >\n                <div class=\"relative flex-1 flex flex-col max-w-xs w-full bg-white\">\n                  <TransitionChild\n                    enter=\"ease-in-out duration-300\"\n                    enterFrom=\"opacity-0\"\n                    enterTo=\"opacity-100\"\n                    leave=\"ease-in-out duration-300\"\n                    leaveFrom=\"opacity-100\"\n                    leaveTo=\"opacity-0\"\n                  >\n                    <div class=\"absolute top-0 right-0 -mr-12 pt-2\">\n                      <button\n                        type=\"button\"\n                        class=\"ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-white\"\n                        onclick={(e) => {\n                          e.redraw = false;\n                          setSidebarOpen(false);\n                        }}\n                      >\n                        <span class=\"sr-only\">Close sidebar</span>\n                        <Icon name=\"close\" class=\"h-6 w-6 text-white\" />\n                      </button>\n                    </div>\n                  </TransitionChild>\n                  <div class=\"flex-1 h-0 pt-5 pb-4 overflow-y-auto\">\n                    <div class=\"flex-shrink-0 flex items-center px-4\">\n                      <img class=\"h-10 w-auto\" src={LOGO_SVG} alt=\"GenieACS\" />\n                    </div>\n                    <nav class=\"mt-5 px-2 flex flex-col gap-1\">\n                      {navigation.map((item) => (\n                        <a\n                          key={item.name}\n                          href={item.href}\n                          class={classNames(\n                            item.active\n                              ? \"bg-stone-100 text-stone-900\"\n                              : \"text-stone-600 hover:bg-stone-50 hover:text-stone-900\",\n                            \"group flex items-center px-2 py-2 text-base font-medium rounded-md\",\n                          )}\n                        >\n                          {item.name}\n                        </a>\n                      ))}\n                    </nav>\n                  </div>\n                  <div class=\"p-2\">\n                    {window.username ? (\n                      <div class=\"flex items-center px-2 text-stone-600 text-base\">\n                        {window.username}\n                        <button\n                          class=\"ml-auto text-base font-medium text-cyan-600 hover:text-cyan-500\"\n                          onclick={(e) => {\n                            e.target.disabled = true;\n                            store\n                              .logOut()\n                              .then(() => {\n                                location.hash = \"\";\n                                location.reload();\n                              })\n                              .catch((err) => {\n                                e.target.disabled = false;\n                                notifications.push(\"error\", err.message);\n                              });\n                            return false;\n                          }}\n                        >\n                          Log out\n                        </button>\n                      </div>\n                    ) : (\n                      <div class=\"px-2\">\n                        <a\n                          class=\"text-base font-medium text-cyan-700 hover:text-cyan-900\"\n                          href=\"\"\n                        >\n                          Log in\n                        </a>\n                      </div>\n                    )}\n                  </div>\n                  <div class=\"text-sm font-mono text-stone-400 text-right p-2\">\n                    v{VERSION}\n                  </div>\n                </div>\n              </TransitionChild>\n              <div class=\"flex-shrink-0 w-14\">\n                {/* Force sidebar to shrink to fit close icon */}\n              </div>\n            </Dialog>\n          </TransitionRoot>\n\n          {/* Static sidebar for desktop */}\n          <div class=\"hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0\">\n            <div class=\"flex-1 flex flex-col min-h-0 border-r border-stone-200 bg-white\">\n              <div class=\"flex-1 flex flex-col pt-5 pb-4 overflow-y-auto\">\n                <div class=\"flex items-center flex-shrink-0 px-4\">\n                  <img class=\"h-10 w-auto\" src={LOGO_SVG} alt=\"GenieACS\" />\n                </div>\n                <nav class=\"mt-5 flex-1 px-2 bg-white flex flex-col gap-1\">\n                  {navigation.map((item) => (\n                    <a\n                      key={item.name}\n                      href={item.href}\n                      class={classNames(\n                        item.active\n                          ? \"bg-stone-100 text-stone-900\"\n                          : \"text-stone-600 hover:bg-stone-50 hover:text-stone-900\",\n                        \"group flex items-center px-2 py-2 text-sm font-medium rounded-md\",\n                      )}\n                    >\n                      {item.name}\n                    </a>\n                  ))}\n                </nav>\n              </div>\n              <div class=\"p-2\">\n                {window.username ? (\n                  <div class=\"flex items-center px-2 text-stone-600 text-sm\">\n                    {window.username}\n                    <button\n                      class=\"ml-auto text-sm font-medium text-cyan-700 hover:text-cyan-900\"\n                      onclick={(e) => {\n                        e.target.disabled = true;\n                        store\n                          .logOut()\n                          .then(() => {\n                            location.hash = \"\";\n                            location.reload();\n                          })\n                          .catch((err) => {\n                            e.target.disabled = false;\n                            notifications.push(\"error\", err.message);\n                          });\n                        return false;\n                      }}\n                    >\n                      Log out\n                    </button>\n                  </div>\n                ) : (\n                  <div class=\"px-2\">\n                    <a\n                      class=\"text-sm font-medium text-cyan-700 hover:text-cyan-900\"\n                      href=\"\"\n                    >\n                      Log in\n                    </a>\n                  </div>\n                )}\n              </div>\n              <div class=\"text-xs font-mono text-stone-400 text-right p-2\">\n                v{VERSION}\n              </div>\n            </div>\n          </div>\n          <div class=\"md:pl-64 flex flex-col flex-1\">\n            <div class=\"sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-stone-100\">\n              <button\n                type=\"button\"\n                class=\"-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-stone-500 hover:text-stone-900 focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-cyan-500\"\n                onclick={(e) => {\n                  setSidebarOpen(true);\n                  e.redraw = false;\n                  return false;\n                }}\n              >\n                <span class=\"sr-only\">Open sidebar</span>\n                <Icon name=\"menu\" class=\"h-6 w-6\" />\n              </button>\n            </div>\n            <main class=\"flex-1\">\n              <div class=\"py-6\">\n                <div class=\"px-4 sm:px-6 md:px-8\">\n                  {m(drawerComponent)}\n                  {vnode.children}\n                </div>\n              </div>\n            </main>\n          </div>\n        </div>,\n        overlay.render(),\n        m(datalist),\n      ];\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/login-page.tsx",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport changePasswordComponent from \"./change-password-component.ts\";\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  return Promise.resolve(args);\n}\n\nexport const component: ClosureComponent = (): Component => {\n  let username = \"\";\n  let password = \"\";\n  let remember = false;\n\n  function logIn(e: MouseEvent): boolean {\n    e.target[\"disabled\"] = true;\n    store\n      .logIn(username, password, remember)\n      .then(() => {\n        location.reload();\n      })\n      .catch((err) => {\n        notifications.push(\"error\", err.response || err.message);\n        e.target[\"disabled\"] = false;\n      });\n    return false;\n  }\n\n  function changePassword(): void {\n    const cb = (): Children => {\n      const attrs = {\n        onPasswordChange: () => {\n          overlay.close(cb);\n          m.redraw();\n        },\n      };\n      return m(changePasswordComponent, attrs);\n    };\n    overlay.open(cb);\n  }\n\n  return {\n    view: (vnode) => {\n      if (window.username) m.route.set(vnode.attrs[\"continue\"] || \"/\");\n\n      document.title = \"Login - GenieACS\";\n\n      return (\n        <div class=\"min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8\">\n          <div class=\"max-w-md w-full flex flex-col gap-8\">\n            <div>\n              <svg\n                class=\"mx-auto h-14 w-auto\"\n                xmlns:xlink=\"http://www.w3.org/1999/xlink\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 64 64\"\n              >\n                <defs>\n                  <linearGradient id=\"a\">\n                    <stop offset=\"0\" stop-color=\"#b72f5f\" />\n                    <stop offset=\"1\" stop-color=\"#872346\" />\n                  </linearGradient>\n                  <linearGradient\n                    xlink:href=\"#a\"\n                    id=\"b\"\n                    gradientUnits=\"userSpaceOnUse\"\n                    gradientTransform=\"matrix(.28375 0 0 -.28375 27.92 37.384)\"\n                    x1=\"16.045\"\n                    y1=\"132.803\"\n                    x2=\"16.045\"\n                    y2=\"-81.164\"\n                  />\n                </defs>\n                <path\n                  d=\"m27.92 37.38 2.6 4.94c-1.15 2.01-2.71 3.74-4.68 5.21-3.22 1.9-5.83 1.59-7.85-.92 3.9-1.76 7.21-4.83 9.93-9.22M18.74 27.2c-.27 2.86-.93 5.67-1.96 8.36-.79 2.08-1.33 4.25-1.6 6.45-.19 1.55.03 3.12.64 4.55.62 1.49 1.73 2.76 3.33 3.82-1.73.52-3.24.66-4.54.43-2.27-.44-3.75-1.81-4.44-4.1a13.48 13.48 0 0 1-.35-6.76c.4-2.11.95-4.18 1.64-6.21a43.64 43.64 0 0 0 1.63-6.46c.14-.83.16-1.7.04-2.6-.14-.96-.49-1.87-1.04-2.67a5.838 5.838 0 0 0-2.27-1.91c.93-.17 1.84-.31 2.75-.43.94-.08 1.89.08 2.75.47 1.12.49 1.95 1.3 2.51 2.41.75 1.42 1.06 3.04.89 4.64zm29.81-10.63-6.98 6.34 2.1 2.1 6.34-6.98.82.84-6.97 6.34 1.97 1.97 6.34-6.97.84.82-6.98 6.34 2.1 2.1 6.67-6.67 1.42 1.39-5.98 5.99-7.8 7.79-4.74 1.94-6.8-5.08 4.49 6.03-1.5.61-6.61-6.61 1.68-4.09 6.18 4.64.01.01.04-.03-.01-.02-5.25-6.99.85-2.09 6.39-6.38 7.38-7.38 1.41 1.39-6.68 6.68 2.09 2.09 6.34-6.97zm4.67 36.63C59.07 47.35 62 40.28 62 31.99c0-8.26-2.93-15.33-8.78-21.21C47.34 4.93 40.27 2 32.01 2c-8.29 0-15.36 2.93-21.21 8.78-3.6 3.61-6.08 7.65-7.47 12.15l.6-.33c2.98-1.57 5.62-.77 7.92 2.41-2.08.43-3.75.87-5.01 1.35-.69.27-1.33.63-1.9 1.09-1.28 1.01-2.08 2.3-2.41 3.86-.48 2.19-.47 4.47.04 6.65 1.08 5.77 3.82 10.85 8.23 15.24 5.85 5.87 12.92 8.8 21.21 8.8 8.28 0 15.35-2.93 21.21-8.8\"\n                  fill=\"url(#b)\"\n                />\n              </svg>\n              <h2 class=\"mt-6 text-center text-3xl font-extrabold text-stone-900\">\n                Log in to continue\n              </h2>\n            </div>\n            <form class=\"mt-8 flex flex-col gap-6\">\n              <div class=\"rounded-md shadow-xs -space-y-px\">\n                <div>\n                  <label for=\"username\" class=\"sr-only\">\n                    Username\n                  </label>\n                  <input\n                    id=\"username\"\n                    name=\"username\"\n                    type=\"text\"\n                    value={username}\n                    autocomplete=\"username\"\n                    required\n                    class=\"appearance-none rounded-none relative block w-full px-3 py-2 border border-stone-300 placeholder-stone-500 text-stone-900 rounded-t-md focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm\"\n                    placeholder=\"Username\"\n                    oninput={(e) => {\n                      username = e.target.value;\n                    }}\n                  />\n                </div>\n                <div>\n                  <label for=\"password\" class=\"sr-only\">\n                    Password\n                  </label>\n                  <input\n                    id=\"password\"\n                    name=\"password\"\n                    type=\"password\"\n                    value={password}\n                    autocomplete=\"current-password\"\n                    required\n                    class=\"appearance-none rounded-none relative block w-full px-3 py-2 border border-stone-300 placeholder-stone-500 text-stone-900 rounded-b-md focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm\"\n                    placeholder=\"Password\"\n                    oninput={(e) => {\n                      password = e.target.value;\n                    }}\n                  />\n                </div>\n              </div>\n\n              <div class=\"flex items-center justify-between\">\n                <div class=\"flex items-center\">\n                  <input\n                    id=\"remember\"\n                    name=\"remember\"\n                    type=\"checkbox\"\n                    value={remember}\n                    class=\"h-4 w-4 text-cyan-700 focus:ring-cyan-500 border-stone-300 rounded-sm\"\n                    onchange={(e) => {\n                      remember = e.target.checked;\n                    }}\n                  />\n                  <label\n                    for=\"remember\"\n                    class=\"ml-2 block text-sm text-stone-900\"\n                  >\n                    Remember me\n                  </label>\n                </div>\n\n                <div class=\"text-sm\">\n                  <button\n                    type=\"button\"\n                    class=\"font-medium text-cyan-700 hover:text-cyan-900\"\n                    onclick={changePassword}\n                  >\n                    Change password\n                  </button>\n                </div>\n              </div>\n\n              <div>\n                <button\n                  type=\"submit\"\n                  class=\"group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\"\n                  onclick={logIn}\n                >\n                  <span class=\"absolute left-0 inset-y-0 flex items-center pl-3\">\n                    <svg\n                      class=\"h-5 w-5 text-cyan-500 group-hover:text-cyan-400\"\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                      viewBox=\"0 0 20 20\"\n                      fill=\"currentColor\"\n                      aria-hidden=\"true\"\n                    >\n                      <path\n                        fill-rule=\"evenodd\"\n                        d=\"M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z\"\n                        clip-rule=\"evenodd\"\n                      />\n                    </svg>\n                  </span>\n                  Log in\n                </button>\n              </div>\n            </form>\n          </div>\n        </div>\n      );\n    },\n  };\n};\n"
  },
  {
    "path": "ui/long-text-component.ts",
    "content": "import m, { ClosureComponent, Component } from \"mithril\";\nimport * as overlay from \"./overlay.ts\";\n\nconst component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      const text = vnode.attrs[\"text\"];\n      const element = vnode.attrs[\"element\"] || \"span\";\n      const className = vnode.attrs[\"class\"] || \"\";\n\n      function overflowed(_vnode): void {\n        _vnode.dom.classList.add(\"cursor-pointer\", \"hover:underline\");\n        _vnode.dom.setAttribute(\"title\", text);\n        _vnode.dom.onclick = (e) => {\n          overlay.open(() => {\n            return m(\n              \"textarea.font-mono text-sm focus:ring-cyan-500 focus:border-cyan-500 border border-stone-300 rounded-md\",\n              {\n                value: text,\n                cols: 80,\n                rows: 24,\n                readonly: \"\",\n                oncreate: (vnode2) => {\n                  (vnode2.dom as HTMLTextAreaElement).focus();\n                },\n              },\n            );\n          });\n          // prevent index page selection\n          e.stopPropagation();\n          m.redraw();\n        };\n      }\n\n      return m(\n        element,\n        {\n          oncreate: (vnode2) => {\n            const w = Math.round(vnode2.dom.getBoundingClientRect().width);\n            if (w !== vnode2.dom.scrollWidth) overflowed(vnode2);\n          },\n          onupdate: (vnode2) => {\n            const w = Math.round(vnode2.dom.getBoundingClientRect().width);\n            if (w === vnode2.dom.scrollWidth) {\n              (vnode2.dom as HTMLElement).classList.remove(\n                \"cursor-pointer\",\n                \"hover:underline\",\n              );\n              (vnode2.dom as HTMLElement).onclick = null;\n              (vnode2.dom as HTMLElement).removeAttribute(\"title\");\n            } else {\n              overflowed(vnode2);\n            }\n          },\n          class: \"block truncate decoration-dotted max-w-full \" + className,\n        },\n        text,\n      );\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/notifications.ts",
    "content": "import m from \"mithril\";\n\ninterface Notification {\n  type: string;\n  message: string;\n  timestamp: number;\n  actions?: { [label: string]: () => void };\n}\n\nconst notifications = new Set<Notification>();\n\nexport function push(\n  type: string,\n  message: string,\n  actions?: { [label: string]: () => void },\n): Notification {\n  const n: Notification = {\n    type: type,\n    message: message,\n    timestamp: Date.now(),\n    actions: actions,\n  };\n  notifications.add(n);\n  m.redraw();\n  if (!actions) {\n    setTimeout(() => {\n      dismiss(n);\n    }, 4000);\n  }\n\n  return n;\n}\n\nexport function dismiss(n: Notification): void {\n  notifications.delete(n);\n  m.redraw();\n}\n\nexport function getNotifications(): Set<Notification> {\n  return notifications;\n}\n"
  },
  {
    "path": "ui/overlay.ts",
    "content": "import m, { Children } from \"mithril\";\nimport { dialog, dialogOverlay, icon } from \"./tailwind-utility-components.ts\";\n\ntype OverlayCallback = () => Children;\ntype CloseCallback = () => boolean;\n\nlet overlayCallback: OverlayCallback = null;\nlet closeCallback: CloseCallback = null;\n\nexport function open(\n  callback: OverlayCallback,\n  closeCb: CloseCallback = null,\n): void {\n  overlayCallback = callback;\n  closeCallback = closeCb;\n}\n\nexport function close(callback: OverlayCallback, force = true): boolean {\n  if (callback === overlayCallback) {\n    if (!force && closeCallback && !closeCallback()) return false;\n    overlayCallback = null;\n    closeCallback = null;\n    return true;\n  }\n\n  return false;\n}\n\nexport function render(): Children {\n  if (overlayCallback) {\n    return m(\n      dialog,\n      {\n        as: \"div\",\n        class: \"fixed z-10 inset-0 overflow-y-auto\",\n        onClose: () => close(overlayCallback, false),\n      },\n      m(\"div.flex items-center justify-center min-h-screen p-4 text-center\", [\n        m(dialogOverlay, {\n          class: \"fixed inset-0 bg-black/50\",\n        }),\n        m(\n          \"div.relative z-10 bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform max-w-full\",\n          m(\n            \"div.block absolute top-0 right-0 pt-4 pr-4\",\n            m(\n              \"button.bg-white rounded-md text-stone-400 hover:text-stone-500 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\",\n              {\n                type: \"button\",\n                onclick: () => close(overlayCallback, false),\n              },\n              m(\"span.sr-only\", \"Close\"),\n              m(icon, { name: \"close\", class: \"h-6 w-6\" }),\n            ),\n          ),\n          overlayCallback(),\n        ),\n      ]),\n    );\n  }\n\n  return null;\n}\n\ndocument.addEventListener(\"keydown\", (e) => {\n  if (overlayCallback && e.key === \"Escape\" && close(overlayCallback, false))\n    m.redraw();\n});\n\nwindow.addEventListener(\"popstate\", () => {\n  if (close(overlayCallback, false)) m.redraw();\n});\n"
  },
  {
    "path": "ui/overview-page.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { overview, rawConf } from \"./config.ts\";\nimport * as store from \"./store.ts\";\nimport pieChartComponent from \"./pie-chart-component.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { ViewComponent } from \"./views.ts\";\n\nconst GROUPS = overview.groups;\nconst CHARTS: typeof overview.charts = {};\nfor (const group of GROUPS) {\n  for (const chartName of group.charts)\n    CHARTS[chartName] = overview.charts[chartName];\n}\n\nfunction queryCharts(charts: typeof overview.charts): typeof charts {\n  charts = Object.assign({}, charts);\n  for (let [chartName, chart] of Object.entries(charts)) {\n    charts[chartName] = chart = { ...chart };\n    chart.slices = chart.slices.map((s) => ({ ...s }));\n    for (const slice of chart.slices) {\n      slice[\"count\"] = store.count(\"devices\", slice.filter);\n    }\n  }\n  return charts;\n}\n\nexport function init(): Promise<{ charts: typeof overview.charts }> {\n  if (!window.authorizer.hasAccess(\"devices\", 1)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  return Promise.resolve({ charts: queryCharts(CHARTS) });\n}\n\ninterface Attrs {\n  charts: typeof overview.charts;\n}\n\nexport const component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      document.title = \"Overview - GenieACS\";\n      const children = [];\n      if (\n        rawConf[\"overview\"] instanceof Expression.Literal &&\n        typeof rawConf[\"overview\"].value === \"string\"\n      ) {\n        return m(ViewComponent, {\n          name: rawConf[\"overview\"].value,\n          attrs: {},\n        });\n      }\n      for (const group of GROUPS) {\n        if (group.label) {\n          children.push(\n            m(\"h1.text-xl font-medium text-stone-900 mb-5\", group[\"label\"]),\n          );\n        }\n\n        const groupChildren = [];\n        for (const chartName of group.charts) {\n          const chart = vnode.attrs.charts[chartName];\n          const chartChildren = [];\n          if (chart.label) {\n            chartChildren.push(\n              m(\n                \"h2.text-lg font-semibold text-stone-700 truncate mb-5 text-center\",\n                chart.label,\n              ),\n            );\n          }\n\n          chartChildren.push(m(pieChartComponent, { chart }));\n\n          groupChildren.push(\n            m(\n              \"div.p-4 bg-white shadow-sm rounded-lg sm:p-6 sm:px-8\",\n              chartChildren,\n            ),\n          );\n        }\n\n        children.push(\n          m(\"div.flex justify-center mt-5 mb-10 gap-x-10\", groupChildren),\n        );\n      }\n\n      return children;\n    },\n  };\n};\n"
  },
  {
    "path": "ui/permissions-page.ts",
    "content": "import { Children, ClosureComponent, Component } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE } from \"./config.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport filterComponent from \"./filter-component.ts\";\n\nconst memoizedParse = memoize((str) => Expression.parse(str));\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes = [\n  { id: \"role\", label: \"Role\" },\n  {\n    id: \"resource\",\n    label: \"Resource\",\n    type: \"combo\",\n    options: [\n      \"config\",\n      \"devices\",\n      \"faults\",\n      \"files\",\n      \"permissions\",\n      \"users\",\n      \"presets\",\n      \"provisions\",\n      \"virtualParameters\",\n      \"views\",\n    ],\n  },\n  { id: \"filter\", label: \"Filter\", type: \"textarea\" },\n  {\n    id: \"access\",\n    label: \"Access\",\n    type: \"combo\",\n    options: [\"1: count\", \"2: read\", \"3: write\"],\n  },\n  { id: \"validate\", label: \"Validate\", type: \"textarea\" },\n];\n\nfunction getExcerpt(text: string, maxLength = 80, maxLines = 10): string[] {\n  let lines: string[] = text?.split(\"\\n\", maxLines + 1) ?? [\"\"];\n\n  if (lines.length > maxLines) {\n    lines.pop();\n    lines[maxLines - 1] = \"\\ufe19\";\n  }\n\n  lines = lines.map((l) => {\n    if (l.length <= maxLength) return l;\n    return l.slice(0, maxLength - 1) + \"\\u2026\";\n  });\n\n  return lines;\n}\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"permissions\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\ninterface ValidationErrors {\n  [prop: string]: string;\n}\n\nfunction putActionHandler(action, _object, isNew): Promise<ValidationErrors> {\n  return new Promise((resolve, reject) => {\n    const object = Object.assign({}, _object);\n    if (action === \"save\") {\n      if (!object.role) return void resolve({ role: \"Role can not be empty\" });\n      if (!object.resource)\n        return void resolve({ resource: \"Resource can not be empty\" });\n      if (!object.access)\n        return void resolve({ access: \"Access can not be empty\" });\n\n      if (object.access === \"3: write\") object.access = 3;\n      else if (object.access === \"2: read\") object.access = 2;\n      else if (object.access === \"1: count\") object.access = 1;\n      else return void resolve({ access: \"Invalid access level\" });\n\n      if (object.filter) {\n        try {\n          object.filter = memoizedParse(object.filter).toString();\n        } catch {\n          return void resolve({\n            filter: \"Filter must be valid expression\",\n          });\n        }\n      }\n\n      if (object.validate) {\n        try {\n          object.validate = memoizedParse(object.validate).toString();\n        } catch {\n          return void resolve({\n            validate: \"Validate must be valid expression\",\n          });\n        }\n      }\n\n      const id = `${object.role}:${object.resource}:${object.access}`;\n\n      store\n        .resourceExists(\"permissions\", id)\n        .then((exists) => {\n          if (exists && isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Permission already exists\" });\n          }\n          if (!exists && !isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Permission does not exist\" });\n          }\n\n          store\n            .putResource(\"permissions\", id, object)\n            .then(() => {\n              notifications.push(\n                \"success\",\n                `Permission ${exists ? \"updated\" : \"created\"}`,\n              );\n              store.setTimestamp(Date.now());\n              resolve(null);\n            })\n            .catch(reject);\n        })\n        .catch(reject);\n    } else if (action === \"delete\") {\n      if (!confirm(\"Deleting permission. Are you sure?\"))\n        return void resolve(null);\n      store\n        .deleteResource(\"permissions\", object[\"_id\"])\n        .then(() => {\n          notifications.push(\"success\", \"Permission deleted\");\n          store.setTimestamp(Date.now());\n          resolve(null);\n        })\n        .catch((err) => {\n          store.setTimestamp(Date.now());\n          reject(err);\n        });\n    } else {\n      reject(new Error(\"Undefined action\"));\n    }\n  });\n}\n\nconst formData = {\n  resource: \"permissions\",\n  attributes: attributes,\n};\n\nconst getDownloadUrl = memoize((filter) => {\n  const cols = {};\n  for (const attr of attributes) cols[attr.label] = attr.id;\n  return `api/permissions.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"permissions\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n  let filter: Expression = null;\n  let sort: Record<string, number> = null;\n  if (args.hasOwnProperty(\"filter\"))\n    filter = Expression.parse(args[\"filter\"] as string);\n  if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n  return Promise.resolve({ filter, sort });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Permissions - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/permissions\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++) {\n        const attr = attributes[i];\n        if (!(attr.id === \"filter\" || attr.id === \"validate\"))\n          sortAttributes[i] = sort[attr.id] || 0;\n      }\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/permissions\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const permissions = store.fetch(\"permissions\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n\n      const count = store.count(\"permissions\", filter);\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const valueCallback = (attr, permission): Children => {\n        if (attr.id === \"access\") {\n          const val = permission[\"access\"];\n          if (val === 1) return \"1: count\";\n          else if (val === 2) return \"2: read\";\n          else if (val === 3) return \"3: write\";\n          return val;\n        } else if (attr.id === \"validate\" || attr.id === \"filter\") {\n          const except = getExcerpt(permission[attr.id], 80, 1);\n          return m(\"span.font-mono\", { title: permission[attr.id] }, except[0]);\n        }\n\n        return permission[attr.id];\n      };\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = permissions.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"valueCallback\"] = valueCallback;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n\n      if (window.authorizer.hasAccess(\"permissions\", 3)) {\n        attrs[\"recordActionsCallback\"] = (permission) => {\n          const val = permission[\"access\"];\n          if (val === 1) permission[\"access\"] = \"1: count\";\n          if (val === 2) permission[\"access\"] = \"2: read\";\n          if (val === 3) permission[\"access\"] = \"3: write\";\n          return [\n            m(\n              \"button.text-cyan-700 hover:text-cyan-900 font-medium\",\n              {\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        base: permission,\n                        oncreate: (_vnode) => {\n                          _vnode.dom.querySelector(\n                            \"input[name='role']\",\n                          ).disabled = true;\n                          _vnode.dom.querySelector(\n                            \"select[name='access']\",\n                          ).disabled = true;\n                          _vnode.dom.querySelector(\n                            \"select[name='resource']\",\n                          ).disabled = true;\n                        },\n                        actionHandler: (action, object) => {\n                          return new Promise<void>((resolve) => {\n                            putActionHandler(action, object, false)\n                              .then((errors) => {\n                                const errorList = errors\n                                  ? Object.values(errors)\n                                  : [];\n                                if (errorList.length) {\n                                  for (const err of errorList)\n                                    notifications.push(\"error\", err);\n                                } else {\n                                  overlay.close(cb);\n                                }\n                                resolve();\n                              })\n                              .catch((err) => {\n                                notifications.push(\"error\", err.message);\n                                resolve();\n                              });\n                          });\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"current\"][\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              \"Show\",\n            ),\n          ];\n        };\n\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return [\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Create new permission\",\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        actionHandler: (action, object) => {\n                          return new Promise<void>((resolve) => {\n                            putActionHandler(action, object, true)\n                              .then((errors) => {\n                                const errorList = errors\n                                  ? Object.values(errors)\n                                  : [];\n                                if (errorList.length) {\n                                  for (const err of errorList)\n                                    notifications.push(\"error\", err);\n                                } else {\n                                  overlay.close(cb);\n                                }\n                                resolve();\n                              })\n                              .catch((err) => {\n                                notifications.push(\"error\", err.message);\n                                resolve();\n                              });\n                          });\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"current\"][\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              \"New\",\n            ),\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Delete selected permissions\",\n                disabled: !selected.size,\n                onclick: (e) => {\n                  if (\n                    !confirm(\n                      `Deleting ${selected.size} permissions. Are you sure?`,\n                    )\n                  )\n                    return;\n\n                  e.redraw = false;\n                  e.target.disabled = true;\n                  Promise.all(\n                    Array.from(selected).map((id) =>\n                      store.deleteResource(\"permissions\", id),\n                    ),\n                  )\n                    .then((res) => {\n                      notifications.push(\n                        \"success\",\n                        `${res.length} permissions deleted`,\n                      );\n                      store.setTimestamp(Date.now());\n                    })\n                    .catch((err) => {\n                      notifications.push(\"error\", err.message);\n                      store.setTimestamp(Date.now());\n                    });\n                },\n              },\n              \"Delete\",\n            ),\n          ];\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"permissions\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing permissions\"),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [permissions, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/pie-chart-component.ts",
    "content": "import { ClosureComponent, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nfunction drawChart(chartData: Attrs[\"chart\"]): Children {\n  const slices = chartData.slices;\n  const total: number = Array.from(Object.values(chartData.slices)).reduce(\n    (a: number, s) => a + (s[\"count\"][\"value\"] || 0),\n    0,\n  );\n  const legend = [];\n  const paths = [];\n  const links = [];\n  let currentProgressPercentage = 0;\n  let startX = Math.cos(2 * Math.PI * currentProgressPercentage) * 100;\n  let startY = Math.sin(2 * Math.PI * currentProgressPercentage) * 100;\n  let endX, endY;\n\n  for (const slice of Object.values(slices)) {\n    const percent = total > 0 ? (slice[\"count\"][\"value\"] || 0) / total : 0;\n    legend.push(\n      m(\"tr\", [\n        m(\n          \"td\",\n          m(\"span.inline-block w-3 h-3 border border-stone-200 mr-1\", {\n            style: `background-color: ${slice.color} !important;`,\n          }),\n        ),\n        m(\"td.w-full\", slice.label),\n        m(\n          \"td.text-stone-500 text-right tabular-nums\",\n          `${Math.round(percent * 100)}%`,\n        ),\n        m(\n          \"td.text-right tabular-nums\",\n          m(\n            \"a.text-cyan-700 hover:text-cyan-900 font-medium ml-2\",\n            {\n              href: `#!/devices/?${m.buildQueryString({\n                filter: slice.filter.toString(),\n              })}`,\n            },\n            slice[\"count\"][\"value\"] || 0,\n          ),\n        ),\n      ]),\n    );\n\n    if (percent > 0) {\n      currentProgressPercentage += percent;\n      endX = Math.cos(2 * Math.PI * currentProgressPercentage) * 100;\n      endY = Math.sin(2 * Math.PI * currentProgressPercentage) * 100;\n      const isBigArc = percent > 0.5 ? 1 : 0;\n\n      const sketch =\n        `M ${startX} ${startY} ` + // Move to the starting point\n        `A 100 100 0 ${isBigArc} 1 ${endX} ${endY} ` + // Draw an Arc from starting point to ending point\n        `L 0 0 z`; // complete the shape by drawing a line to the center of circle\n\n      startX = endX;\n      startY = endY;\n\n      paths.push(\n        m(\"path.stroke-white stroke-1\", {\n          d: sketch,\n          fill: slice.color,\n        }),\n      );\n\n      const percentageX =\n        Math.cos(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50;\n      const percentageY =\n        Math.sin(2 * Math.PI * (currentProgressPercentage - percent / 2)) * 50;\n\n      links.push(\n        m(\n          \"a.opacity-0 hover:opacity-100 focus-visible:opacity-100 outline-hidden\",\n          {\n            \"xlink:href\": `#!/devices/?${m.buildQueryString({\n              filter: slice.filter.toString(),\n            })}`,\n          },\n          [\n            m(\"path.stroke-cyan-500 stroke-1\", {\n              d: sketch,\n              \"fill-opacity\": 0,\n            }),\n            m(\n              \"text.opacity-40 font-medium fill-black\",\n              {\n                x: percentageX,\n                y: percentageY,\n                \"dominant-baseline\": \"middle\",\n                \"text-anchor\": \"middle\",\n              },\n              `${Math.round(percent * 100)}%`,\n            ),\n          ],\n        ),\n      );\n    }\n  }\n\n  legend.push(\n    m(\n      \"tr\",\n      m(\"td\", \"\"),\n      m(\"td\", { colspan: 2 }, \"Total\"),\n      m(\"td.text-right tabular-nums\", total),\n    ),\n  );\n\n  return m(\n    \"loading\",\n    {\n      queries: Object.values(chartData.slices).map((s) => s[\"count\"]),\n    },\n    m(\"div\", [\n      m(\n        \"svg.m-4\",\n        {\n          // Adding 2 as padding; strokes must not be more than 2\n          viewBox: \"-102 -102 204 204\",\n          width: \"204px\",\n          height: \"204px\",\n          xmlns: \"http://www.w3.org/2000/svg\",\n          \"xmlns:xlink\": \"http://www.w3.org/1999/xlink\",\n        },\n        paths.concat(links),\n      ),\n      m(\"table.mt-8 text-sm\", legend),\n    ]),\n  );\n}\n\ninterface Attrs {\n  chart: {\n    label: string;\n    slices: {\n      label: string;\n      filter: Expression;\n      color: string;\n    }[];\n  };\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      return drawChart(vnode.attrs.chart);\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/presets-page.ts",
    "content": "import { ClosureComponent, Component, Children, Vnode } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE } from \"./config.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nconst memoizedParse = memoize((str) => Expression.parse(str));\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes = [\n  { id: \"_id\", label: \"Name\" },\n  { id: \"channel\", label: \"Channel\" },\n  { id: \"weight\", label: \"Weight\" },\n  { id: \"schedule\", label: \"Schedule\" },\n  { id: \"events\", label: \"Events\" },\n  { id: \"precondition\", label: \"Precondition\", type: \"textarea\" },\n  { id: \"provision\", label: \"Provision\", type: \"combo\" },\n  { id: \"provisionArgs\", label: \"Arguments\", type: \"textarea\" },\n];\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"presets\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\ninterface ValidationErrors {\n  [prop: string]: string;\n}\n\nfunction getExcerpt(text: string, maxLength = 80, maxLines = 10): string[] {\n  let lines: string[] = text?.split(\"\\n\", maxLines + 1) ?? [\"\"];\n\n  if (lines.length > maxLines) {\n    lines.pop();\n    lines[maxLines - 1] = \"\\ufe19\";\n  }\n\n  lines = lines.map((l) => {\n    if (l.length <= maxLength) return l;\n    return l.slice(0, maxLength - 1) + \"\\u2026\";\n  });\n\n  return lines;\n}\n\nfunction putActionHandler(action, _object, isNew): Promise<ValidationErrors> {\n  return new Promise((resolve, reject) => {\n    const object = Object.assign({}, _object);\n    if (action === \"save\") {\n      const id = object[\"_id\"];\n      delete object[\"_id\"];\n\n      const errors = {};\n\n      if (!id) errors[\"_id\"] = \"ID can not be empty\";\n      if (!object.provision) errors[\"provision\"] = \"Provision not selected\";\n\n      if (Object.keys(errors).length) return void resolve(errors);\n\n      if (object.precondition) {\n        try {\n          object.precondition = memoizedParse(object.precondition).toString();\n        } catch {\n          return void resolve({\n            precondition: \"Precondition must be valid expression\",\n          });\n        }\n      }\n\n      store\n        .resourceExists(\"presets\", id)\n        .then((exists) => {\n          if (exists && isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Preset already exists\" });\n          }\n\n          if (!exists && !isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Preset does not exist\" });\n          }\n\n          store\n            .putResource(\"presets\", id, object)\n            .then(() => {\n              notifications.push(\n                \"success\",\n                `Preset ${exists ? \"updated\" : \"created\"}`,\n              );\n              store.setTimestamp(Date.now());\n              resolve(null);\n            })\n            .catch(reject);\n        })\n        .catch(reject);\n    } else if (action === \"delete\") {\n      if (!confirm(\"Deleting preset. Are you sure?\")) return void resolve(null);\n\n      store\n        .deleteResource(\"presets\", object[\"_id\"])\n        .then(() => {\n          notifications.push(\"success\", \"Preset deleted\");\n          store.setTimestamp(Date.now());\n          resolve(null);\n        })\n        .catch((err) => {\n          reject(err);\n          store.setTimestamp(Date.now());\n        });\n    } else {\n      reject(new Error(\"Undefined action\"));\n    }\n  });\n}\n\nconst formData = {\n  resource: \"presets\",\n  attributes: attributes,\n};\n\nconst getDownloadUrl = memoize((filter) => {\n  const cols = {};\n  for (const attr of attributes) cols[attr.label] = attr.id;\n  return `api/presets.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"presets\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  let filter: Expression = null;\n  let sort: Record<string, number> = null;\n  if (args.hasOwnProperty(\"filter\"))\n    filter = Expression.parse(args[\"filter\"] as string);\n  if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n  return Promise.resolve({ filter, sort });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Presets - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/presets\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++) {\n        const attr = attributes[i];\n        if (\n          !(\n            attr.id === \"events\" ||\n            attr.id === \"precondition\" ||\n            attr.id === \"provision\" ||\n            attr.id === \"provisionArgs\"\n          )\n        )\n          sortAttributes[i] = sort[attr.id] || 0;\n      }\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/presets\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const presets = store.fetch(\"presets\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n      const count = store.count(\"presets\", filter);\n\n      const userDefinedProvisions: Set<string> = new Set();\n\n      const provisionIds = new Set([\n        \"refresh\",\n        \"value\",\n        \"tag\",\n        \"reboot\",\n        \"reset\",\n        \"download\",\n        \"instances\",\n      ]);\n\n      const provisions = store.fetch(\n        \"provisions\",\n        new Expression.Literal(true),\n      );\n      if (provisions.fulfilled) {\n        for (const p of provisions.value) {\n          userDefinedProvisions.add(p[\"_id\"]);\n          provisionIds.add(p[\"_id\"]);\n        }\n      }\n\n      const provisionAttr = attributes.find((attr) => {\n        return attr.id === \"provision\";\n      });\n      provisionAttr[\"options\"] = Array.from(provisionIds);\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const valueCallback = (attr, preset): Vnode => {\n        if (attr.id === \"precondition\") {\n          let devicesUrl = \"#!/devices\";\n          if (preset[\"precondition\"].length) {\n            devicesUrl += `?${m.buildQueryString({\n              filter: preset[\"precondition\"],\n            })}`;\n          }\n\n          return m(\n            \"a.text-cyan-700 hover:text-cyan-900 font-mono\",\n            { href: devicesUrl, title: preset[\"precondition\"] },\n            getExcerpt(preset[\"precondition\"], 80, 1)[0],\n          );\n        } else if (attr.id === \"provisionArgs\") {\n          return m(\n            \"span.font-mono\",\n            { title: preset[\"provisionArgs\"] },\n            getExcerpt(preset[\"provisionArgs\"], 80, 1)[0],\n          );\n        } else if (\n          attr.id === \"provision\" &&\n          userDefinedProvisions.has(preset[attr.id])\n        ) {\n          return m(\n            \"a.text-cyan-700 hover:text-cyan-900\",\n            {\n              href: `#!/provisions?${m.buildQueryString({\n                filter: `Q(\"ID\", \"${preset[\"provision\"]}\")`,\n              })}`,\n            },\n            preset[\"provision\"],\n          );\n        } else {\n          return preset[attr.id];\n        }\n      };\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = presets.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n      attrs[\"valueCallback\"] = valueCallback;\n      attrs[\"recordActionsCallback\"] = (preset) => {\n        return [\n          m(\n            \"button.text-cyan-700 hover:text-cyan-900 font-medium\",\n            {\n              onclick: () => {\n                let cb: () => Children = null;\n                const comp = m(\n                  putFormComponent,\n                  Object.assign(\n                    {\n                      base: preset,\n                      actionHandler: (action, object) => {\n                        return new Promise<void>((resolve) => {\n                          putActionHandler(action, object, false)\n                            .then((errors) => {\n                              const errorList = errors\n                                ? Object.values(errors)\n                                : [];\n                              if (errorList.length) {\n                                for (const err of errorList)\n                                  notifications.push(\"error\", err);\n                              } else {\n                                overlay.close(cb);\n                              }\n                              resolve();\n                            })\n                            .catch((err) => {\n                              notifications.push(\"error\", err.message);\n                              resolve();\n                            });\n                        });\n                      },\n                    },\n                    formData,\n                  ),\n                );\n                cb = (): Children => {\n                  if (!preset.provision) {\n                    return m(\n                      \"div\",\n                      { style: \"margin:20px\" },\n                      \"This UI only supports presets with a single 'provision' configuration. If this preset was originally created from the old UI (genieacs-gui), you must edit it there.\",\n                    );\n                  }\n                  return comp;\n                };\n                overlay.open(\n                  cb,\n                  () =>\n                    !comp.state?.[\"current\"][\"modified\"] ||\n                    confirm(\"You have unsaved changes. Close anyway?\"),\n                );\n              },\n            },\n            \"Show\",\n          ),\n        ];\n      };\n\n      if (window.authorizer.hasAccess(\"presets\", 3)) {\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return [\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Create new preset\",\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        actionHandler: (action, object) => {\n                          return new Promise<void>((resolve) => {\n                            putActionHandler(action, object, true)\n                              .then((errors) => {\n                                const errorList = errors\n                                  ? Object.values(errors)\n                                  : [];\n                                if (errorList.length) {\n                                  for (const err of errorList)\n                                    notifications.push(\"error\", err);\n                                } else {\n                                  overlay.close(cb);\n                                }\n                                resolve();\n                              })\n                              .catch((err) => {\n                                notifications.push(\"error\", err.message);\n                                resolve();\n                              });\n                          });\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"current\"][\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              \"New\",\n            ),\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Delete selected presets\",\n                disabled: !selected.size,\n                onclick: (e) => {\n                  if (\n                    !confirm(`Deleting ${selected.size} presets. Are you sure?`)\n                  )\n                    return;\n\n                  e.redraw = false;\n                  e.target.disabled = true;\n                  Promise.all(\n                    Array.from(selected).map((id) =>\n                      store.deleteResource(\"presets\", id),\n                    ),\n                  )\n                    .then((res) => {\n                      notifications.push(\n                        \"success\",\n                        `${res.length} presets deleted`,\n                      );\n                      store.setTimestamp(Date.now());\n                    })\n                    .catch((err) => {\n                      notifications.push(\"error\", err.message);\n                      store.setTimestamp(Date.now());\n                    });\n                },\n              },\n              \"Delete\",\n            ),\n          ];\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"presets\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing presets\"),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [presets, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/provisions-page.ts",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE } from \"./config.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { loadCodeMirror } from \"./dynamic-loader.ts\";\n\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes = [\n  { id: \"_id\", label: \"Name\" },\n  { id: \"script\", label: \"Script\", type: \"code\", mode: \"javascript\" },\n];\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"provisions\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\ninterface ValidationErrors {\n  [prop: string]: string;\n}\n\nfunction putActionHandler(action, _object, isNew): Promise<ValidationErrors> {\n  return new Promise((resolve, reject) => {\n    const object = Object.assign({}, _object);\n    if (action === \"save\") {\n      const id = object[\"_id\"];\n      delete object[\"_id\"];\n\n      if (!id) return void resolve({ _id: \"ID can not be empty\" });\n\n      store\n        .resourceExists(\"provisions\", id)\n        .then((exists) => {\n          if (exists && isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Provision already exists\" });\n          }\n\n          if (!exists && !isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Provision does not exist\" });\n          }\n\n          store\n            .putResource(\"provisions\", id, object)\n            .then(() => {\n              notifications.push(\n                \"success\",\n                `Provision ${exists ? \"updated\" : \"created\"}`,\n              );\n              store.setTimestamp(Date.now());\n              resolve(null);\n            })\n            .catch((err) => {\n              if (err[\"code\"] === 400 && err[\"response\"]) {\n                reject(new Error(err[\"response\"]));\n                return;\n              }\n              reject(err);\n            });\n        })\n        .catch(reject);\n    } else if (action === \"delete\") {\n      if (!confirm(\"Deleting provision. Are you sure?\"))\n        return void resolve(null);\n      store\n        .deleteResource(\"provisions\", object[\"_id\"])\n        .then(() => {\n          notifications.push(\"success\", \"Provision deleted\");\n          store.setTimestamp(Date.now());\n          resolve(null);\n        })\n        .catch((err) => {\n          store.setTimestamp(Date.now());\n          reject(err);\n        });\n    } else {\n      reject(new Error(\"Undefined action\"));\n    }\n  });\n}\n\nconst formData = {\n  resource: \"provisions\",\n  attributes: attributes,\n};\n\nconst getDownloadUrl = memoize((filter) => {\n  const cols = {};\n  for (const attr of attributes) cols[attr.label] = attr.id;\n  return `api/provisions.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"provisions\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  let filter: Expression = null;\n  let sort: Record<string, number> = null;\n  if (args.hasOwnProperty(\"filter\"))\n    filter = Expression.parse(args[\"filter\"] as string);\n  if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n\n  return new Promise((resolve, reject) => {\n    loadCodeMirror()\n      .then(() => {\n        resolve({ filter, sort });\n      })\n      .catch(reject);\n  });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Provisions - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/provisions\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++)\n        sortAttributes[i] = sort[attributes[i].id] || 0;\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/provisions\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const provisions = store.fetch(\"provisions\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n\n      const count = store.count(\"provisions\", filter);\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = provisions.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n      attrs[\"recordActionsCallback\"] = (provision) => {\n        return [\n          m(\n            \"button.text-cyan-700 hover:text-cyan-900 font-medium\",\n            {\n              onclick: () => {\n                let cb: () => Children = null;\n                const comp = m(\n                  putFormComponent,\n                  Object.assign(\n                    {\n                      base: provision,\n                      actionHandler: (action, object) => {\n                        return new Promise<void>((resolve) => {\n                          putActionHandler(action, object, false)\n                            .then((errors) => {\n                              const errorList = errors\n                                ? Object.values(errors)\n                                : [];\n                              if (errorList.length) {\n                                for (const err of errorList)\n                                  notifications.push(\"error\", err);\n                              } else {\n                                overlay.close(cb);\n                              }\n                              resolve();\n                            })\n                            .catch((err) => {\n                              notifications.push(\"error\", err.message);\n                              resolve();\n                            });\n                        });\n                      },\n                    },\n                    formData,\n                  ),\n                );\n                cb = () => comp;\n                overlay.open(\n                  cb,\n                  () =>\n                    !comp.state[\"current\"][\"modified\"] ||\n                    confirm(\"You have unsaved changes. Close anyway?\"),\n                );\n              },\n            },\n            \"Show\",\n          ),\n        ];\n      };\n\n      if (window.authorizer.hasAccess(\"provisions\", 3)) {\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return [\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Create new provision\",\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        actionHandler: (action, object) => {\n                          return new Promise<void>((resolve) => {\n                            putActionHandler(action, object, true)\n                              .then((errors) => {\n                                const errorList = errors\n                                  ? Object.values(errors)\n                                  : [];\n                                if (errorList.length) {\n                                  for (const err of errorList)\n                                    notifications.push(\"error\", err);\n                                } else {\n                                  overlay.close(cb);\n                                }\n                                resolve();\n                              })\n                              .catch((err) => {\n                                notifications.push(\"error\", err.message);\n                                resolve();\n                              });\n                          });\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"current\"][\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              \"New\",\n            ),\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Delete selected provisions\",\n                disabled: !selected.size,\n                onclick: (e) => {\n                  if (\n                    !confirm(\n                      `Deleting ${selected.size} provisions. Are you sure?`,\n                    )\n                  )\n                    return;\n\n                  e.redraw = false;\n                  e.target.disabled = true;\n                  Promise.all(\n                    Array.from(selected).map((id) =>\n                      store.deleteResource(\"provisions\", id),\n                    ),\n                  )\n                    .then((res) => {\n                      notifications.push(\n                        \"success\",\n                        `${res.length} provisions deleted`,\n                      );\n                      store.setTimestamp(Date.now());\n                    })\n                    .catch((err) => {\n                      notifications.push(\"error\", err.message);\n                      store.setTimestamp(Date.now());\n                    });\n                },\n              },\n              \"Delete\",\n            ),\n          ];\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"provisions\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing provisions\"),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [provisions, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/put-form-component.ts",
    "content": "import { VnodeDOM, ClosureComponent, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport codeEditorComponent from \"./code-editor-component.ts\";\nimport { getDatalistId } from \"./datalist.ts\";\n\nconst singular = {\n  presets: \"preset\",\n  provisions: \"provision\",\n  virtualParameters: \"virtual parameter\",\n  files: \"file\",\n  users: \"user\",\n  permissions: \"permission\",\n  views: \"view\",\n};\n\nfunction createField(current, attr, focus): Children {\n  if (attr.type === \"combo\") {\n    let selected = \"\";\n    let optionsValues = attr.options;\n    if (current.object[attr.id] != null) {\n      if (!optionsValues.includes(current.object[attr.id]))\n        optionsValues = optionsValues.concat([current.object[attr.id]]);\n      selected = current.object[attr.id];\n    }\n\n    const options = [m(\"option\", { value: \"\" }, \"\")];\n    for (const op of optionsValues)\n      options.push(m(\"option\", { value: op }, op));\n\n    return m(\n      \"select.mt-1 block pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md\",\n      {\n        name: attr.id,\n        value: selected,\n        oncreate: focus\n          ? (_vnode) => {\n              (_vnode.dom as HTMLSelectElement).focus();\n            }\n          : null,\n        onchange: (e) => {\n          current.object[attr.id] = e.target.value;\n          current.modified = true;\n          e.redraw = false;\n        },\n      },\n      options,\n    );\n  } else if (attr.type === \"multi\") {\n    const optionsValues = Array.from(\n      new Set(attr.options.concat(current.object[attr.id] || [])),\n    );\n    const currentSelected = new Set(current.object[attr.id]);\n    const options = optionsValues.map((op) => {\n      const id = `${attr.id}-${op}`;\n      const opts = {\n        type: \"checkbox\",\n        id: id,\n        value: op,\n        oncreate: (_vnode) => {\n          if (focus && !options.length) _vnode.dom.focus();\n          if (currentSelected.has(op)) _vnode.dom.checked = true;\n        },\n        onchange: (e) => {\n          if (e.target.checked) currentSelected.add(op);\n          else currentSelected.delete(op);\n          current.object[attr.id] = Array.from(currentSelected);\n          current.modified = true;\n          e.redraw = false;\n        },\n      };\n\n      return m(\"tr\", [\n        m(\n          \"td\",\n          m(\n            \"input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm\",\n            opts,\n          ),\n        ),\n        m(\"td\", op),\n      ]);\n    });\n\n    return m(\"table\", options);\n  } else if (attr.type === \"code\") {\n    const attrs = {\n      id: attr.id,\n      value: current.object[attr.id],\n      mode: attr.mode || \"javascript\",\n      onSubmit: (dom) => {\n        dom.form.querySelector(\"button[type=submit]\").click();\n      },\n      onChange: (value) => {\n        current.object[attr.id] = value;\n        current.modified = true;\n      },\n    };\n    return m(codeEditorComponent, attrs);\n  } else if (attr.type === \"file\") {\n    return m(\"input\", {\n      type: \"file\",\n      name: attr.id,\n      oncreate: focus\n        ? (_vnode) => {\n            (_vnode.dom as HTMLInputElement).focus();\n          }\n        : null,\n      onchange: (e) => {\n        current.object[attr.id] = e.target.files;\n        current.modified = true;\n        e.redraw = false;\n      },\n    });\n  } else if (attr.type === \"textarea\") {\n    return m(\n      \"textarea.shadow-xs block focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm border border-stone-300 rounded-md\",\n      {\n        name: attr.id,\n        value: current.object[attr.id],\n        readonly: attr.id === \"_id\" && !current.isNew,\n        cols: attr.cols || 80,\n        rows: attr.rows || 4,\n        style: \"resize: none;\",\n        oncreate: focus\n          ? (_vnode) => {\n              const dom = _vnode.dom as HTMLInputElement;\n              dom.focus();\n              dom.setSelectionRange(dom.value.length, dom.value.length);\n            }\n          : null,\n        oninput: (e) => {\n          current.object[attr.id] = e.target.value;\n          current.modified = true;\n          e.redraw = false;\n        },\n        onkeypress: (e) => {\n          e.redraw = false;\n          if (e.which === 13 && !e.shiftKey) {\n            const dom = e.target;\n            dom.form.querySelector(\"button[type=submit]\").click();\n            return false;\n          }\n          return true;\n        },\n      },\n    );\n  }\n\n  let datalist: string = null;\n  if (attr.options) datalist = getDatalistId(attr.options);\n\n  return m(\n    \"input.shadow-xs focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md\",\n    {\n      type: attr.type === \"password\" ? \"password\" : \"text\",\n      name: attr.id,\n      list: datalist,\n      autocomplete: datalist ? \"off\" : null,\n      disabled: attr.id === \"_id\" && !current.isNew,\n      value: current.object[attr.id],\n      oncreate: focus\n        ? (_vnode) => {\n            (_vnode.dom as HTMLInputElement).focus();\n          }\n        : null,\n      oninput: (e) => {\n        current.object[attr.id] = e.target.value;\n        current.modified = true;\n        e.redraw = false;\n      },\n    },\n  );\n}\n\ninterface Attrs {\n  base?: Record<string, any>;\n  actionHandler: (action: string, object: any) => Promise<void>;\n  resource: string;\n  attributes: {\n    id: string;\n    label: string;\n    type?: string;\n    options?: string[];\n  }[];\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      const actionHandler = vnode.attrs.actionHandler;\n      const attributes = vnode.attrs.attributes;\n      const resource = vnode.attrs.resource;\n      const base = vnode.attrs.base || {};\n      if (!vnode.state[\"current\"]) {\n        vnode.state[\"current\"] = {\n          isNew: !base[\"_id\"],\n          object: Object.assign({}, base),\n          modified: false,\n        };\n      }\n\n      const current = vnode.state[\"current\"];\n\n      const form = [];\n      let focused = false;\n      for (const attr of attributes) {\n        let focus = false;\n        if (!focused && (current.isNew || attr.id !== \"_id\"))\n          focus = focused = true;\n\n        form.push(\n          m(\n            \"p\",\n            m(\n              \"label.block text-sm font-semibold text-stone-700 mt-2 mb-1\",\n              { for: attr.id },\n              attr.label || attr.id,\n            ),\n            createField(current, attr, focus),\n          ),\n        );\n      }\n\n      const buttons: VnodeDOM[] = [];\n\n      if (!current.isNew) {\n        buttons.push(\n          m(\n            \"button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500\",\n            {\n              type: \"button\",\n              title: `Delete ${singular[resource] || resource}`,\n              onclick: (e) => {\n                e.redraw = false;\n                e.target.disabled = true;\n                void actionHandler(\"delete\", current.object).finally(() => {\n                  e.target.disabled = false;\n                });\n              },\n            },\n            \"Delete\",\n          ) as VnodeDOM,\n        );\n      }\n\n      const submit = m(\n        \"button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\",\n        { type: \"submit\" },\n        \"Save\",\n      ) as VnodeDOM;\n\n      buttons.push(submit);\n\n      form.push(m(\"div.flex justify-end mt-5\", buttons));\n\n      const children = [\n        m(\n          \"h2.text-lg leading-6 font-medium text-stone-900\",\n          `${current.isNew ? \"New\" : \"Editing\"} ${\n            singular[resource] || resource\n          }`,\n        ),\n        m(\n          \"form\",\n          {\n            onsubmit: (e) => {\n              e.redraw = false;\n              // const onsubmit = e.target.onsubmit;\n              e.preventDefault();\n              // e.target.onsubmit = null;\n              (submit.dom as HTMLFormElement).disabled = true;\n              // submit.dom.textContent = \"Loading ...\";\n              void actionHandler(\"save\", current.object).finally(() => {\n                // submit.dom.textContent = \"Save\";\n                // e.target.onsubmit = onsubmit;\n                (submit.dom as HTMLFormElement).disabled = false;\n              });\n            },\n          },\n          form,\n        ),\n      ];\n\n      return m(\"div\", children);\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/reactive-store.ts",
    "content": "import m from \"mithril\";\nimport {\n  SignalBase,\n  ComputedSignal,\n  ComputedState,\n  Watcher,\n  registerDependency,\n} from \"./signals.ts\";\nimport { xhrRequest } from \"./store.ts\";\nimport { SkewedDate } from \"./skewed-date.ts\";\nimport { subtract, covers } from \"../lib/common/expression/synth.ts\";\nimport {\n  bookmarkToExpression,\n  toBookmark,\n} from \"../lib/common/expression/pagination.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\n\nconst memoizedStringify = memoize((e: Expression) => e.toString());\n\nfunction evaluate(\n  exp: Expression,\n  timestamp: number,\n  obj: Record<string, unknown>,\n): Expression {\n  return exp.evaluate((e) => {\n    if (e instanceof Expression.Literal) return e;\n    else if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"NOW\") return new Expression.Literal(timestamp);\n    } else if (e instanceof Expression.Parameter && obj) {\n      let v = obj[e.path.toString()];\n      if (v == null) return new Expression.Literal(null);\n      if (typeof v === \"object\")\n        v = (v as Record<string, unknown>)[\"value\"]?.[0];\n      return new Expression.Literal(v as string | number | boolean | null);\n    }\n    return e;\n  });\n}\n\ntype BookmarkData = Record<string, null | boolean | number | string>;\n\nexport interface QueryState<T> {\n  value: T;\n  timestamp: number; // 0 means never fetched\n  loading: boolean;\n}\n\ninterface FetchedRegion {\n  filter: Expression;\n  timestamp: number;\n  filterStr: string;\n}\n\ninterface CachedCount {\n  value: number;\n  timestamp: number;\n}\n\ninterface CachedBookmark {\n  data: BookmarkData | null;\n  timestamp: number;\n}\n\ninterface ResourceCache {\n  objects: Map<string, unknown>; // Cached objects by ID\n  counts: Map<string, CachedCount>; // Count cache keyed by stringified filter\n  bookmarks: Map<string, CachedBookmark>; // Bookmark cache keyed by (filter, sort, offset)\n  fetchedRegions: FetchedRegion[]; // Tracks what filter regions were fetched when\n}\n\nexport class Bookmark {\n  constructor(\n    private _data: BookmarkData,\n    private _sort: Record<string, number>,\n  ) {}\n\n  // bookmarkToExpression returns condition for rows <= bookmark position\n  applySkip(filter: Expression): Expression {\n    const condition = bookmarkToExpression(this._data, this._sort);\n    return Expression.and(filter, condition);\n  }\n\n  // NOT(rows <= bookmark) = rows > bookmark\n  applyLimit(filter: Expression): Expression {\n    const condition = bookmarkToExpression(this._data, this._sort);\n    return Expression.and(filter, new Expression.Unary(\"NOT\", condition));\n  }\n}\n\nexport class QuerySignal<T> extends SignalBase<QueryState<T>> {\n  declare _sinks: Set<globalThis.WeakRef<ComputedSignal<unknown> | Watcher>>;\n  private _state: QueryState<T>;\n\n  constructor(initialValue: T) {\n    super();\n    this._sinks = new Set();\n    this._state = {\n      value: initialValue,\n      timestamp: 0,\n      loading: true,\n    };\n  }\n\n  get(): QueryState<T> {\n    if (this._disposed) throw new Error(\"Cannot read disposed signal\");\n    registerDependency(this);\n    return this._state;\n  }\n\n  // Returns the state without registering a dependency\n  _peek(): QueryState<T> {\n    if (this._disposed) throw new Error(\"Cannot read disposed signal\");\n    return this._state;\n  }\n\n  _update(value: T, timestamp: number, loading: boolean): void {\n    const changed =\n      !Object.is(this._state.value, value) ||\n      this._state.timestamp !== timestamp ||\n      this._state.loading !== loading;\n\n    if (changed) {\n      this._state = { value, timestamp, loading };\n      this._markSinksDirty();\n    }\n  }\n\n  private _markSinksDirty(): void {\n    for (const weakRef of this._sinks) {\n      const sink = weakRef.deref();\n      if (sink === undefined) {\n        this._sinks.delete(weakRef);\n        continue;\n      }\n      if (sink instanceof Watcher) {\n        sink._notify();\n        continue;\n      }\n      sink._state = ComputedState.Dirty;\n      this._markSinksChecking(sink._sinks);\n    }\n  }\n\n  private _markSinksChecking(\n    sinks: Set<globalThis.WeakRef<ComputedSignal<unknown> | Watcher>>,\n  ): void {\n    for (const weakRef of sinks) {\n      const sink = weakRef.deref();\n      if (sink === undefined) {\n        sinks.delete(weakRef);\n        continue;\n      }\n      if (sink instanceof Watcher) {\n        sink._notify();\n        continue;\n      }\n      if ((sink as { _state: ComputedState })._state === ComputedState.Clean) {\n        (sink as { _state: ComputedState })._state = ComputedState.Checking;\n        this._markSinksChecking((sink as ComputedSignal<unknown>)._sinks);\n      }\n    }\n  }\n\n  [Symbol.dispose](): void {\n    if (this._disposed) return;\n    this._disposed = true;\n    this._sinks.clear();\n  }\n}\n\nfunction compareFunction(\n  sort: Record<string, number>,\n): (a: unknown, b: unknown) => number {\n  return (a, b) => {\n    for (const [param, asc] of Object.entries(sort)) {\n      let v1 = (a as Record<string, unknown>)[param];\n      let v2 = (b as Record<string, unknown>)[param];\n      if (v1 != null && typeof v1 === \"object\") {\n        const v1Obj = v1 as { value?: unknown[] };\n        if (v1Obj.value) v1 = v1Obj.value[0];\n        else v1 = null;\n      }\n      if (v2 != null && typeof v2 === \"object\") {\n        const v2Obj = v2 as { value?: unknown[] };\n        if (v2Obj.value) v2 = v2Obj.value[0];\n        else v2 = null;\n      }\n      if (v1 > v2) {\n        return asc;\n      } else if (v1 < v2) {\n        return asc * -1;\n      } else if (v1 !== v2) {\n        const w: Record<string, number> = {\n          null: 1,\n          number: 2,\n          string: 3,\n        };\n        const w1 = w[v1 == null ? \"null\" : typeof v1] || 4;\n        const w2 = w[v2 == null ? \"null\" : typeof v2] || 4;\n        return Math.max(-1, Math.min(1, w1 - w2)) * asc;\n      }\n    }\n    return 0;\n  };\n}\n\nfunction getObjectId(resourceType: string, obj: unknown): string {\n  const record = obj as Record<string, unknown>;\n  if (resourceType === \"devices\")\n    return (record[\"DeviceID.ID\"] as string) ?? \"\";\n  return (record[\"_id\"] as string) ?? \"\";\n}\n\ninterface FetchQueryEntry {\n  weakRef: globalThis.WeakRef<QuerySignal<unknown[]>>;\n  filter: Expression;\n  sort: Record<string, number>;\n}\n\ninterface CountQueryEntry {\n  weakRef: globalThis.WeakRef<QuerySignal<number>>;\n  filter: Expression;\n}\n\ninterface BookmarkQueryEntry {\n  weakRef: globalThis.WeakRef<QuerySignal<Bookmark | null>>;\n  filter: Expression;\n  sort: Record<string, number>;\n  offset: number;\n}\n\nclass ResourceStore {\n  private cache: ResourceCache;\n  private fetchQueries: Map<string, FetchQueryEntry>;\n  private countQueries: Map<string, CountQueryEntry>;\n  private bookmarkQueries: Map<string, BookmarkQueryEntry>;\n  private registry: globalThis.FinalizationRegistry<{\n    type: string;\n    key: string;\n  }>;\n\n  constructor(private resourceType: string) {\n    this.cache = {\n      objects: new Map(),\n      counts: new Map(),\n      bookmarks: new Map(),\n      fetchedRegions: [],\n    };\n    this.fetchQueries = new Map();\n    this.countQueries = new Map();\n    this.bookmarkQueries = new Map();\n\n    this.registry = new globalThis.FinalizationRegistry(({ type, key }) => {\n      this.onQueryDisposed(type, key);\n    });\n  }\n\n  fetch(\n    filter: Expression,\n    sort: Record<string, number>,\n    freshness: number,\n  ): QuerySignal<unknown[]> {\n    const filterStr = memoizedStringify(filter);\n    const key = `${filterStr}:${JSON.stringify(sort)}`;\n\n    const existingEntry = this.fetchQueries.get(key);\n    if (existingEntry) {\n      const existing = existingEntry.weakRef.deref();\n      if (existing) {\n        // Use _peek() to avoid registering a dependency on the caller\n        const state = existing._peek();\n        if (state.timestamp < freshness && !state.loading) {\n          existing._update(state.value, state.timestamp, true);\n          this.triggerFetchRefresh(filter, sort, existing, freshness);\n        }\n        return existing;\n      }\n    }\n\n    const signal = new QuerySignal<unknown[]>([]);\n    const weakRef = new globalThis.WeakRef(signal);\n    this.fetchQueries.set(key, { weakRef, filter, sort });\n    this.registry.register(signal, { type: \"fetch\", key });\n\n    const cachedData = this.findMatchingObjects(filter, sort);\n    const { covered, oldestTimestamp } = this.checkCoverage(filter, freshness);\n\n    if (cachedData.length > 0) {\n      signal._update(cachedData, oldestTimestamp, !covered);\n    }\n\n    if (!covered) {\n      this.triggerFetchRefresh(filter, sort, signal, freshness);\n    } else {\n      signal._update(cachedData, oldestTimestamp, false);\n    }\n\n    return signal;\n  }\n\n  count(filter: Expression, freshness: number): QuerySignal<number> {\n    const filterStr = memoizedStringify(filter);\n\n    const existingEntry = this.countQueries.get(filterStr);\n    if (existingEntry) {\n      const existing = existingEntry.weakRef.deref();\n      if (existing) {\n        // Use _peek() to avoid registering a dependency on the caller\n        const state = existing._peek();\n        if (state.timestamp < freshness && !state.loading) {\n          existing._update(state.value, state.timestamp, true);\n          this.triggerCountRefresh(filter, existing);\n        }\n        return existing;\n      }\n    }\n\n    const signal = new QuerySignal<number>(0);\n    const weakRef = new globalThis.WeakRef(signal);\n    this.countQueries.set(filterStr, { weakRef, filter });\n    this.registry.register(signal, { type: \"count\", key: filterStr });\n\n    const cached = this.cache.counts.get(filterStr);\n    if (cached && cached.timestamp >= freshness) {\n      signal._update(cached.value, cached.timestamp, false);\n    } else {\n      if (cached) {\n        signal._update(cached.value, cached.timestamp, true);\n      }\n      this.triggerCountRefresh(filter, signal);\n    }\n\n    return signal;\n  }\n\n  createBookmark(\n    filter: Expression,\n    sort: Record<string, number>,\n    offset: number,\n    freshness: number,\n    after?: Bookmark,\n  ): QuerySignal<Bookmark | null> {\n    const effectiveFilter = after ? after.applySkip(filter) : filter;\n    const filterStr = memoizedStringify(effectiveFilter);\n    const key = `${filterStr}:${JSON.stringify(sort)}:${offset}`;\n\n    const existingEntry = this.bookmarkQueries.get(key);\n    if (existingEntry) {\n      const existing = existingEntry.weakRef.deref();\n      if (existing) {\n        // Use _peek() to avoid registering a dependency on the caller\n        const state = existing._peek();\n        if (state.timestamp < freshness && !state.loading) {\n          this.triggerBookmarkRefresh(effectiveFilter, sort, offset, existing);\n        }\n        return existing;\n      }\n    }\n\n    const signal = new QuerySignal<Bookmark | null>(null);\n    const weakRef = new globalThis.WeakRef(signal);\n    this.bookmarkQueries.set(key, {\n      weakRef,\n      filter: effectiveFilter,\n      sort,\n      offset,\n    });\n    this.registry.register(signal, { type: \"bookmark\", key });\n\n    const cached = this.cache.bookmarks.get(key);\n    if (cached && cached.timestamp >= freshness) {\n      const bookmark = cached.data ? new Bookmark(cached.data, sort) : null;\n      signal._update(bookmark, cached.timestamp, false);\n    } else {\n      if (cached) {\n        const bookmark = cached.data ? new Bookmark(cached.data, sort) : null;\n        signal._update(bookmark, cached.timestamp, true);\n      }\n      this.triggerBookmarkRefresh(effectiveFilter, sort, offset, signal);\n    }\n\n    return signal;\n  }\n\n  private getCombinedFilter(minTimestamp: number): Expression {\n    return this.cache.fetchedRegions\n      .filter((region) => region.timestamp >= minTimestamp)\n      .reduce(\n        (acc, region) => Expression.or(acc, region.filter),\n        new Expression.Literal(false) as Expression,\n      );\n  }\n\n  private checkCoverage(\n    filter: Expression,\n    freshness: number,\n  ): { covered: boolean; diff: Expression; oldestTimestamp: number } {\n    const freshRegions = this.cache.fetchedRegions.filter(\n      (region) => region.timestamp >= freshness,\n    );\n\n    if (freshRegions.length === 0) {\n      return { covered: false, diff: filter, oldestTimestamp: 0 };\n    }\n\n    const combined = freshRegions.reduce(\n      (acc, region) => Expression.or(acc, region.filter),\n      new Expression.Literal(false) as Expression,\n    );\n    const oldestTimestamp = Math.min(...freshRegions.map((r) => r.timestamp));\n    const diff = subtract(combined, filter);\n\n    return {\n      covered: diff instanceof Expression.Literal && !diff.value,\n      diff,\n      oldestTimestamp,\n    };\n  }\n\n  private findMatchingObjects(\n    filter: Expression,\n    sort: Record<string, number>,\n  ): unknown[] {\n    const now = SkewedDate.now();\n    const matches: unknown[] = [];\n\n    for (const obj of this.cache.objects.values()) {\n      const result = evaluate(filter, now, obj as Record<string, unknown>);\n      if (result instanceof Expression.Literal && !!result.value) {\n        matches.push(obj);\n      }\n    }\n\n    return matches.sort(compareFunction(sort));\n  }\n\n  // Subtract new region from existing regions to maintain non-overlapping regions\n  private addFetchedRegion(filter: Expression, timestamp: number): void {\n    const filterStr = memoizedStringify(filter);\n    const updatedRegions: FetchedRegion[] = [];\n\n    for (const region of this.cache.fetchedRegions) {\n      const remainder = subtract(filter, region.filter);\n      if (!(remainder instanceof Expression.Literal && !remainder.value)) {\n        updatedRegions.push({\n          filter: remainder,\n          timestamp: region.timestamp,\n          filterStr: memoizedStringify(remainder),\n        });\n      }\n    }\n\n    updatedRegions.push({\n      filter,\n      timestamp,\n      filterStr,\n    });\n\n    this.cache.fetchedRegions = updatedRegions;\n  }\n\n  // TODO: Consider batching concurrent fetch requests for the same resource.\n  // Currently each query issues its own XHR. When multiple queries are\n  // triggered at the same time (e.g. after invalidation), they race and\n  // fetch overlapping data independently. A batching mechanism could combine\n  // them into fewer requests by deferring execution to the next microtask and\n  // merging the filters.\n  private triggerFetchRefresh(\n    filter: Expression,\n    sort: Record<string, number>,\n    signal: QuerySignal<unknown[]>,\n    freshness: number,\n  ): void {\n    const doFetch = async (retryCount = 0): Promise<void> => {\n      try {\n        const combined = this.getCombinedFilter(freshness);\n        const diff = subtract(combined, filter);\n\n        if (diff instanceof Expression.Literal && !diff.value) {\n          const data = this.findMatchingObjects(filter, sort);\n          signal._update(data, Date.now(), false);\n          return;\n        }\n\n        const filterStr = memoizedStringify(diff);\n        const res = await xhrRequest({\n          method: \"GET\",\n          url:\n            `api/${this.resourceType}/?` +\n            m.buildQueryString({\n              filter: filterStr,\n            }),\n          background: true,\n        });\n\n        const returnedIds = new Set<string>();\n        for (const obj of res as unknown[]) {\n          const id = getObjectId(this.resourceType, obj);\n          if (id) {\n            this.cache.objects.set(id, obj);\n            returnedIds.add(id);\n          }\n        }\n        for (const obj of this.findMatchingObjects(filter, {})) {\n          const id = getObjectId(this.resourceType, obj);\n          if (!returnedIds.has(id)) this.cache.objects.delete(id);\n        }\n\n        const now = Date.now();\n        this.addFetchedRegion(diff, now);\n        const data = this.findMatchingObjects(filter, sort);\n        signal._update(data, now, false);\n      } catch (err) {\n        console.error(\n          `Error fetching ${this.resourceType}:`,\n          (err as Error).message,\n        );\n        if (retryCount < 1) {\n          await new Promise((resolve) => globalThis.setTimeout(resolve, 1000));\n          return doFetch(retryCount + 1);\n        }\n        const state = signal.get();\n        signal._update(state.value, state.timestamp, false);\n      }\n    };\n\n    void doFetch();\n  }\n\n  private triggerCountRefresh(\n    filter: Expression,\n    signal: QuerySignal<number>,\n  ): void {\n    const doCount = async (retryCount = 0): Promise<void> => {\n      try {\n        const filterStr = memoizedStringify(filter);\n        const countValue = await xhrRequest({\n          method: \"HEAD\",\n          url:\n            `api/${this.resourceType}/?` +\n            m.buildQueryString({\n              filter: filterStr,\n            }),\n          extract: (xhr: XMLHttpRequest) => {\n            if (xhr.status === 403) throw new Error(\"Not authorized\");\n            if (!xhr.status) throw new Error(\"Server is unreachable\");\n            if (xhr.status !== 200) {\n              throw new Error(`Unexpected response status code ${xhr.status}`);\n            }\n            return +xhr.getResponseHeader(\"x-total-count\")!;\n          },\n          background: true,\n        });\n\n        const now = Date.now();\n        this.cache.counts.set(filterStr, { value: countValue, timestamp: now });\n        signal._update(countValue, now, false);\n      } catch (err) {\n        console.error(\n          `Error counting ${this.resourceType}:`,\n          (err as Error).message,\n        );\n        if (retryCount < 1) {\n          await new Promise((resolve) => globalThis.setTimeout(resolve, 1000));\n          return doCount(retryCount + 1);\n        }\n        const state = signal.get();\n        signal._update(state.value, state.timestamp, false);\n      }\n    };\n\n    void doCount();\n  }\n\n  private triggerBookmarkRefresh(\n    filter: Expression,\n    sort: Record<string, number>,\n    offset: number,\n    signal: QuerySignal<Bookmark | null>,\n  ): void {\n    const doBookmark = async (retryCount = 0): Promise<void> => {\n      try {\n        const filterStr = memoizedStringify(filter);\n        const projection = Object.keys(sort).join(\",\");\n\n        const res = await xhrRequest({\n          method: \"GET\",\n          url:\n            `api/${this.resourceType}/?` +\n            m.buildQueryString({\n              filter: filterStr,\n              skip: offset,\n              limit: 1,\n              sort: JSON.stringify(sort),\n              projection,\n            }),\n          background: true,\n        });\n\n        const now = Date.now();\n        const key = `${filterStr}:${JSON.stringify(sort)}:${offset}`;\n\n        let bookmarkData: BookmarkData | null = null;\n        if ((res as unknown[]).length > 0) {\n          bookmarkData = toBookmark(sort, (res as unknown[])[0]);\n        }\n\n        this.cache.bookmarks.set(key, { data: bookmarkData, timestamp: now });\n        const bookmark = bookmarkData ? new Bookmark(bookmarkData, sort) : null;\n        signal._update(bookmark, now, false);\n      } catch (err) {\n        console.error(\n          `Error creating bookmark for ${this.resourceType}:`,\n          (err as Error).message,\n        );\n        if (retryCount < 1) {\n          await new Promise((resolve) => globalThis.setTimeout(resolve, 1000));\n          return doBookmark(retryCount + 1);\n        }\n        const state = signal.get();\n        signal._update(state.value, state.timestamp, false);\n      }\n    };\n\n    void doBookmark();\n  }\n\n  invalidate(timestamp: number): void {\n    // Invalidate queries whose data was fetched strictly before the given\n    // timestamp. The timestamp is exclusive: data fetched at exactly the\n    // given timestamp is considered fresh.\n    for (const [, entry] of this.fetchQueries) {\n      const signal = entry.weakRef.deref();\n      if (!signal || signal._disposed) continue;\n      const state = signal._peek();\n      if (state.timestamp < timestamp && !state.loading) {\n        signal._update(state.value, state.timestamp, true);\n        this.triggerFetchRefresh(entry.filter, entry.sort, signal, timestamp);\n      }\n    }\n\n    // Invalidate count queries\n    for (const [, entry] of this.countQueries) {\n      const signal = entry.weakRef.deref();\n      if (!signal || signal._disposed) continue;\n      const state = signal._peek();\n      if (state.timestamp < timestamp && !state.loading) {\n        signal._update(state.value, state.timestamp, true);\n        this.triggerCountRefresh(entry.filter, signal);\n      }\n    }\n\n    // Invalidate bookmark queries\n    for (const [, entry] of this.bookmarkQueries) {\n      const signal = entry.weakRef.deref();\n      if (!signal || signal._disposed) continue;\n      const state = signal._peek();\n      if (state.timestamp < timestamp && !state.loading) {\n        signal._update(state.value, state.timestamp, true);\n        this.triggerBookmarkRefresh(\n          entry.filter,\n          entry.sort,\n          entry.offset,\n          signal,\n        );\n      }\n    }\n  }\n\n  private onQueryDisposed(type: string, key: string): void {\n    if (type === \"fetch\") {\n      this.fetchQueries.delete(key);\n      this.pruneCache();\n    } else if (type === \"count\") {\n      this.countQueries.delete(key);\n      this.cache.counts.delete(key);\n    } else if (type === \"bookmark\") {\n      this.bookmarkQueries.delete(key);\n      this.cache.bookmarks.delete(key);\n    }\n  }\n\n  private pruneCache(): void {\n    const neededFilters: Expression[] = [];\n\n    for (const [key, entry] of this.fetchQueries) {\n      const signal = entry.weakRef.deref();\n      if (!signal || signal._disposed) {\n        this.fetchQueries.delete(key);\n        continue;\n      }\n      neededFilters.push(entry.filter);\n    }\n\n    if (neededFilters.length === 0) {\n      this.cache.objects.clear();\n      this.cache.fetchedRegions = [];\n      return;\n    }\n\n    const combinedNeeded = neededFilters.reduce(\n      (acc, filter) => Expression.or(acc, filter),\n      new Expression.Literal(false) as Expression,\n    );\n\n    const keptRegions: FetchedRegion[] = [];\n\n    for (const region of this.cache.fetchedRegions) {\n      const intersection = Expression.and(region.filter, combinedNeeded);\n      if (!covers(new Expression.Literal(false), intersection)) {\n        keptRegions.push(region);\n      }\n    }\n\n    this.cache.fetchedRegions = keptRegions;\n\n    if (keptRegions.length === 0) {\n      this.cache.objects.clear();\n    } else {\n      const keptCombined = keptRegions.reduce(\n        (acc, region) => Expression.or(acc, region.filter),\n        new Expression.Literal(false) as Expression,\n      );\n\n      const now = SkewedDate.now();\n      for (const [id, obj] of this.cache.objects) {\n        const result = evaluate(\n          keptCombined,\n          now,\n          obj as Record<string, unknown>,\n        );\n        if (!(result instanceof Expression.Literal && !!result.value)) {\n          this.cache.objects.delete(id);\n        }\n      }\n    }\n  }\n}\n\nconst stores: Map<string, ResourceStore> = new Map();\n\nfunction getStore(resource: string): ResourceStore {\n  let store = stores.get(resource);\n  if (!store) {\n    store = new ResourceStore(resource);\n    stores.set(resource, store);\n  }\n  return store;\n}\n\nfunction applyDefaultSort(\n  resourceType: string,\n  sort?: Record<string, number>,\n): Record<string, number> {\n  const result = Object.assign({}, sort);\n  if (resourceType === \"devices\") {\n    result[\"DeviceID.ID\"] = result[\"DeviceID.ID\"] || 1;\n  } else {\n    result[\"_id\"] = result[\"_id\"] || 1;\n  }\n  return result;\n}\n\nexport function fetch(\n  resource: string,\n  filter: Expression,\n  options: {\n    sort?: Record<string, number>;\n    freshness?: number;\n  } = {},\n): QuerySignal<unknown[]> {\n  const sort = applyDefaultSort(resource, options.sort);\n  const freshness = options.freshness ?? 0;\n  return getStore(resource).fetch(filter, sort, freshness);\n}\n\nexport function count(\n  resource: string,\n  filter: Expression,\n  options: { freshness?: number } = {},\n): QuerySignal<number> {\n  const freshness = options.freshness ?? 0;\n  return getStore(resource).count(filter, freshness);\n}\n\nexport function createBookmark(\n  resource: string,\n  filter: Expression,\n  sort: Record<string, number>,\n  offset: number,\n  options: {\n    freshness?: number;\n    after?: Bookmark;\n  } = {},\n): QuerySignal<Bookmark | null> {\n  const normalizedSort = applyDefaultSort(resource, sort);\n  const freshness = options.freshness ?? 0;\n  return getStore(resource).createBookmark(\n    filter,\n    normalizedSort,\n    offset,\n    freshness,\n    options.after,\n  );\n}\n\nexport function invalidate(timestamp: number): void {\n  for (const store of stores.values()) {\n    store.invalidate(timestamp);\n  }\n}\n"
  },
  {
    "path": "ui/signals.ts",
    "content": "// Reactive signals system based on the TC39 Signals proposal.\n// https://github.com/tc39/proposal-signals\n\nexport const enum ComputedState {\n  Clean,\n  Computing,\n  Checking,\n  Dirty,\n}\n\n// Creates a Proxy that hides underscore-prefixed properties\nfunction createSafeProxy<T extends object>(target: T): T {\n  return new Proxy(target, {\n    get(obj, prop) {\n      if (typeof prop === \"string\" && prop.startsWith(\"_\")) {\n        return undefined;\n      }\n      const value = Reflect.get(obj, prop, obj);\n      if (typeof value === \"function\") {\n        return value.bind(obj);\n      }\n      return value;\n    },\n    set(obj, prop, value) {\n      if (typeof prop === \"string\" && prop.startsWith(\"_\")) {\n        return false;\n      }\n      return Reflect.set(obj, prop, value, obj);\n    },\n    ownKeys(obj) {\n      return Reflect.ownKeys(obj).filter(\n        (key) => typeof key !== \"string\" || !key.startsWith(\"_\"),\n      );\n    },\n    getOwnPropertyDescriptor(obj, prop) {\n      if (typeof prop === \"string\" && prop.startsWith(\"_\")) {\n        return undefined;\n      }\n      return Reflect.getOwnPropertyDescriptor(obj, prop);\n    },\n  });\n}\n\n// Tracks the currently computing signal for automatic dependency registration\nlet computing: ComputedSignal<unknown> | null = null;\n\nexport function registerDependency(source: SignalBase<unknown>): void {\n  if (computing !== null) {\n    source._sinks.add(computing._selfRef);\n    computing._sources.add(source);\n  }\n}\n\nfunction registerCleanup(cleanup: () => void): void {\n  if (computing !== null) {\n    computing._cleanups.add(cleanup);\n  }\n}\n\nfunction runCleanups(signal: ComputedSignal<unknown>): void {\n  for (const cleanup of signal._cleanups) {\n    cleanup();\n  }\n  signal._cleanups.clear();\n}\n\n// Type for sinks: can be ComputedSignal or Watcher.\ntype Sink = ComputedSignal<unknown> | Watcher;\n\nfunction markSinksChecking(sinks: Set<WeakRef<Sink>>): void {\n  for (const weakRef of sinks) {\n    const sink = weakRef.deref();\n    if (sink === undefined) {\n      sinks.delete(weakRef);\n      continue;\n    }\n\n    // eslint-disable-next-line @typescript-eslint/no-use-before-define\n    if (sink instanceof Watcher) {\n      sink._notify();\n      continue;\n    }\n\n    // Only promote Clean to Checking (Dirty/Checking stay as-is)\n    if (sink._state === ComputedState.Clean) {\n      sink._state = ComputedState.Checking;\n      markSinksChecking(sink._sinks);\n    }\n  }\n}\n\nfunction markSinksDirty(sinks: Set<WeakRef<Sink>>): void {\n  for (const weakRef of sinks) {\n    const sink = weakRef.deref();\n    if (sink === undefined) {\n      sinks.delete(weakRef);\n      continue;\n    }\n\n    // eslint-disable-next-line @typescript-eslint/no-use-before-define\n    if (sink instanceof Watcher) {\n      sink._notify();\n      continue;\n    }\n\n    // Promote Clean or Checking to Dirty\n    if (\n      sink._state === ComputedState.Clean ||\n      sink._state === ComputedState.Checking\n    ) {\n      runCleanups(sink);\n      sink._state = ComputedState.Dirty;\n      markSinksChecking(sink._sinks); // Indirect dependents become Checking\n    }\n  }\n}\n\nexport abstract class SignalBase<T = unknown> implements Disposable {\n  declare _sinks: Set<WeakRef<Sink>>;\n  _disposed: boolean = false;\n\n  abstract get(): T;\n  abstract [Symbol.dispose](): void;\n}\n\nexport class ConstSignal<T> extends SignalBase<T> {\n  private _value: T;\n\n  constructor(value: T) {\n    super();\n    this._value = value;\n\n    // Register disposal if created inside a computation\n    if (computing !== null) {\n      registerCleanup(() => this[Symbol.dispose]());\n    }\n  }\n\n  get(): T {\n    if (this._disposed) throw new Error(\"Cannot read disposed signal\");\n    return this._value;\n  }\n\n  [Symbol.dispose](): void {\n    if (this._disposed) return;\n    this._disposed = true;\n    this._value = undefined as T;\n  }\n}\n\nexport class StateSignal<T> extends SignalBase<T> {\n  private _value: T;\n\n  constructor(initialValue: T) {\n    super();\n    this._sinks = new Set();\n    this._value = initialValue;\n\n    // Register disposal if created inside a computation\n    if (computing !== null) {\n      registerCleanup(() => this[Symbol.dispose]());\n    }\n  }\n\n  get(): T {\n    if (this._disposed) throw new Error(\"Cannot read disposed signal\");\n    registerDependency(this);\n    return this._value;\n  }\n\n  set(newValue: T): void {\n    if (this._disposed) throw new Error(\"Cannot write to disposed signal\");\n    if (Object.is(this._value, newValue)) return;\n    this._value = newValue;\n    markSinksDirty(this._sinks);\n  }\n\n  [Symbol.dispose](): void {\n    if (this._disposed) return;\n    this._disposed = true;\n    this._value = undefined as T;\n    this._sinks.clear();\n  }\n}\n\nexport class ComputedSignal<T> extends SignalBase<T> {\n  private _callback: () => T;\n  private _value: T | undefined;\n  private _error: unknown;\n  private _hasError: boolean = false;\n\n  _state: ComputedState = ComputedState.Dirty;\n  _sources: Set<SignalBase<unknown>> = new Set();\n  _cleanups: Set<() => void> = new Set();\n\n  // Single WeakRef reused when registering with sources for memory efficiency\n  readonly _selfRef: WeakRef<ComputedSignal<unknown>>;\n\n  constructor(callback: () => T) {\n    super();\n    this._sinks = new Set();\n    this._callback = callback;\n    this._selfRef = new WeakRef(this as ComputedSignal<unknown>);\n\n    // Register disposal if created inside a computation\n    if (computing !== null) {\n      registerCleanup(() => this[Symbol.dispose]());\n    }\n  }\n\n  get(): T {\n    if (this._disposed) throw new Error(\"Cannot read disposed signal\");\n    registerDependency(this);\n\n    if (this._state === ComputedState.Computing) {\n      throw new Error(\"Circular dependency detected\");\n    }\n\n    if (!this._isValid()) return this._recompute();\n\n    if (this._hasError) throw this._error;\n    return this._value as T;\n  }\n\n  _isValid(): boolean {\n    if (this._state === ComputedState.Clean) return true;\n    if (this._state !== ComputedState.Checking) return false;\n    // Checking: verify if sources have changed\n    for (const source of this._sources) {\n      if (source instanceof ComputedSignal) {\n        source.get(); // Triggers recomputation if source is Dirty/Checking\n        // If source's value changed, it would have marked us Dirty\n        if ((this._state as ComputedState) === ComputedState.Dirty)\n          return false;\n      }\n    }\n    // All sources unchanged, we're clean\n    this._state = ComputedState.Clean;\n    return true;\n  }\n\n  private _recompute(): T {\n    // Clear old dependencies\n    for (const source of this._sources) {\n      source._sinks.delete(this._selfRef);\n    }\n    this._sources.clear();\n\n    const prevComputing = computing;\n    computing = this as ComputedSignal<unknown>;\n    this._state = ComputedState.Computing;\n\n    try {\n      const oldValue = this._value;\n      const hadError = this._hasError;\n      this._value = this._callback();\n      this._hasError = false;\n      this._state = ComputedState.Clean;\n      // If value changed, mark sinks dirty (for Checking optimization)\n      if (hadError || !Object.is(oldValue, this._value)) {\n        markSinksDirty(this._sinks);\n      }\n      return this._value;\n    } catch (e) {\n      const oldError = this._error;\n      const hadError = this._hasError;\n      this._error = e;\n      this._hasError = true;\n      this._state = ComputedState.Clean;\n      // If error changed, mark sinks dirty\n      if (!hadError || !Object.is(oldError, e)) {\n        markSinksDirty(this._sinks);\n      }\n      throw e;\n    } finally {\n      computing = prevComputing;\n    }\n  }\n\n  [Symbol.dispose](): void {\n    if (this._disposed) return;\n    this._disposed = true;\n\n    // Run all registered cleanups (clears timeouts/intervals and disposes\n    // nested signals)\n    runCleanups(this as ComputedSignal<unknown>);\n\n    // Detach from sources\n    for (const source of this._sources) {\n      source._sinks?.delete(this._selfRef);\n    }\n    this._sources.clear();\n\n    // Clear sinks\n    this._sinks.clear();\n\n    // Release references for GC\n    this._value = undefined;\n    this._error = undefined;\n  }\n}\n\n// Observes signal changes from outside the reactive graph.\n// Based on the TC39 Signals proposal's Signal.subtle.Watcher.\n// The notify callback fires synchronously and should be lightweight\n// (e.g., just schedule a redraw).\nexport class Watcher implements Disposable {\n  private _callback: () => void;\n  private _notified: boolean = false;\n  private _disposed: boolean = false;\n  private _watching: Set<SignalBase<unknown>> = new Set();\n  readonly _selfRef: WeakRef<Watcher>;\n\n  constructor(notify: () => void) {\n    this._callback = notify;\n    this._selfRef = new WeakRef(this);\n  }\n\n  // Also resets the notified flag, allowing the callback to fire again.\n  watch(...signals: SignalBase<unknown>[]): void {\n    for (const signal of signals) {\n      if (!signal._sinks) continue; // ConstSignal has no sinks\n      signal._sinks.add(this._selfRef);\n      this._watching.add(signal);\n    }\n    this._notified = false;\n  }\n\n  unwatch(...signals: SignalBase<unknown>[]): void {\n    for (const signal of signals) {\n      if (!signal._sinks) continue;\n      signal._sinks.delete(this._selfRef);\n      this._watching.delete(signal);\n    }\n  }\n\n  // Only ComputedSignals can be dirty/checking; StateSignals are always current.\n  getPending(): SignalBase<unknown>[] {\n    const pending: SignalBase<unknown>[] = [];\n    for (const signal of this._watching) {\n      if (signal instanceof ComputedSignal) {\n        if (signal._state !== ComputedState.Clean) {\n          pending.push(signal);\n        }\n      }\n    }\n    return pending;\n  }\n\n  _notify(): void {\n    if (this._disposed || this._notified) return;\n    this._notified = true;\n    this._callback();\n  }\n\n  [Symbol.dispose](): void {\n    if (this._disposed) return;\n    this._disposed = true;\n    for (const signal of this._watching) {\n      signal._sinks?.delete(this._selfRef);\n    }\n    this._watching.clear();\n  }\n}\n\n// Safe signal wrappers that hide internal properties via Proxy.\n// Exposed to user scripts as Signal.State, Signal.Computed, and Signal.Const.\nclass SafeConstSignal<T> extends ConstSignal<T> {\n  static [Symbol.hasInstance](instance: unknown): boolean {\n    return instance instanceof ConstSignal;\n  }\n\n  constructor(value: T) {\n    super(value);\n    return createSafeProxy(this);\n  }\n}\n\nclass SafeStateSignal<T> extends StateSignal<T> {\n  static [Symbol.hasInstance](instance: unknown): boolean {\n    return instance instanceof StateSignal;\n  }\n\n  constructor(initialValue: T) {\n    super(initialValue);\n    return createSafeProxy(this);\n  }\n}\n\nclass SafeComputedSignal<T> extends ComputedSignal<T> {\n  static [Symbol.hasInstance](instance: unknown): boolean {\n    return instance instanceof ComputedSignal;\n  }\n\n  constructor(callback: () => T) {\n    super(callback);\n    return createSafeProxy(this);\n  }\n}\n\nexport const Signal = {\n  Const: SafeConstSignal,\n  State: SafeStateSignal,\n  Computed: SafeComputedSignal,\n  [Symbol.hasInstance](instance: unknown): boolean {\n    return instance instanceof SignalBase;\n  },\n};\n\n// setTimeout wrapper that skips the callback if the enclosing computed\n// signal is no longer valid when the timeout fires. Outside a computed\n// signal, behaves exactly like globalThis.setTimeout.\nexport function setTimeout<TArgs extends unknown[]>(\n  callback: (...callbackArgs: TArgs) => void,\n  delay?: number,\n  ...args: TArgs\n): ReturnType<typeof globalThis.setTimeout> {\n  const signal = computing;\n\n  if (signal === null) {\n    return globalThis.setTimeout(callback, delay, ...args);\n  }\n\n  const timeoutId = globalThis.setTimeout(\n    (...callbackArgs: TArgs) => {\n      if (signal._isValid()) {\n        callback(...callbackArgs);\n      }\n    },\n    delay,\n    ...args,\n  );\n  registerCleanup(() => globalThis.clearTimeout(timeoutId));\n  return timeoutId;\n}\n\n// setInterval wrapper that clears the interval when the enclosing computed\n// signal becomes dirty or is recomputed. Outside a computed signal, behaves\n// exactly like globalThis.setInterval.\nexport function setInterval<TArgs extends unknown[]>(\n  callback: (...callbackArgs: TArgs) => void,\n  delay?: number,\n  ...args: TArgs\n): ReturnType<typeof globalThis.setInterval> {\n  const signal = computing;\n\n  if (signal === null) {\n    return globalThis.setInterval(callback, delay, ...args);\n  }\n\n  const intervalId = globalThis.setInterval(\n    (...callbackArgs: TArgs) => {\n      if (signal._isValid()) {\n        callback(...callbackArgs);\n      } else {\n        globalThis.clearInterval(intervalId);\n      }\n    },\n    delay,\n    ...args,\n  );\n  registerCleanup(() => globalThis.clearInterval(intervalId));\n  return intervalId;\n}\n"
  },
  {
    "path": "ui/skewed-date.ts",
    "content": "export function getClockSkew(): number {\n  return window.clockSkew;\n}\n\nexport class SkewedDate extends Date {\n  constructor(...args: unknown[]) {\n    if (args.length === 0) {\n      super(Date.now() + getClockSkew());\n    } else {\n      super(...(args as [any]));\n    }\n  }\n\n  static override now(): number {\n    return Date.now() + getClockSkew();\n  }\n}\n"
  },
  {
    "path": "ui/smart-query.ts",
    "content": "import { filters } from \"./config.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { encodeTag } from \"../lib/util.ts\";\nimport Path from \"../lib/common/path.ts\";\n\nconst resources = {\n  devices: {},\n  faults: {\n    Device: {\n      parameter: new Expression.Parameter(Path.parse(\"device\")),\n      type: \"string\",\n    },\n    Channel: {\n      parameter: new Expression.Parameter(Path.parse(\"channel\")),\n      type: \"string\",\n    },\n    Code: {\n      parameter: new Expression.Parameter(Path.parse(\"code\")),\n      type: \"string\",\n    },\n    Retries: {\n      parameter: new Expression.Parameter(Path.parse(\"retries\")),\n      type: \"number\",\n    },\n    Timestamp: {\n      parameter: new Expression.Parameter(Path.parse(\"timestamp\")),\n      type: \"timestamp\",\n    },\n  },\n  presets: {\n    ID: {\n      parameter: new Expression.Parameter(Path.parse(\"_id\")),\n      type: \"string\",\n    },\n    Channel: {\n      parameter: new Expression.Parameter(Path.parse(\"channel\")),\n      type: \"string\",\n    },\n    Weight: {\n      parameter: new Expression.Parameter(Path.parse(\"weight\")),\n      type: \"number\",\n    },\n  },\n  provisions: {\n    ID: {\n      parameter: new Expression.Parameter(Path.parse(\"_id\")),\n      type: \"string\",\n    },\n  },\n  virtualParameters: {\n    ID: {\n      parameter: new Expression.Parameter(Path.parse(\"_id\")),\n      type: \"string\",\n    },\n  },\n  files: {\n    ID: {\n      parameter: new Expression.Parameter(Path.parse(\"_id\")),\n      type: \"string\",\n    },\n    Type: {\n      parameter: new Expression.Parameter(Path.parse(\"metadata.fileType\")),\n      type: \"string\",\n    },\n    OUI: {\n      parameter: new Expression.Parameter(Path.parse(\"metadata.oui\")),\n      type: \"string\",\n    },\n    \"Product class\": {\n      parameter: new Expression.Parameter(Path.parse(\"metadata.productClass\")),\n      type: \"string\",\n    },\n    Version: {\n      parameter: new Expression.Parameter(Path.parse(\"metadata.version\")),\n      type: \"string\",\n    },\n  },\n  permissions: {\n    Role: {\n      parameter: new Expression.Parameter(Path.parse(\"role\")),\n      type: \"string\",\n    },\n    Resource: {\n      parameter: new Expression.Parameter(Path.parse(\"resource\")),\n      type: \"string\",\n    },\n    Access: {\n      parameter: new Expression.Parameter(Path.parse(\"access\")),\n      type: \"number\",\n    },\n  },\n  users: {\n    Username: {\n      parameter: new Expression.Parameter(Path.parse(\"_id\")),\n      type: \"string\",\n    },\n  },\n  views: {\n    ID: {\n      parameter: new Expression.Parameter(Path.parse(\"_id\")),\n      type: \"string\",\n    },\n  },\n};\n\nfor (const v of filters) {\n  resources.devices[v.label] = {\n    parameter: v.parameter,\n    type: (v.type || \"\").split(\",\").map((s) => s.trim()),\n  };\n}\n\nexport function getLabels(resource: string): string[] {\n  if (!resources[resource]) return [];\n  return Object.keys(resources[resource]);\n}\n\nfunction queryNumber(param: Expression, value: string): Expression {\n  let op = \"=\";\n  for (const o of [\"<>\", \"=\", \"<=\", \"<\", \">=\", \">\"]) {\n    if (value.startsWith(o)) {\n      op = o;\n      value = value.slice(o.length).trim();\n      break;\n    }\n  }\n\n  const v = parseInt(value);\n  if (v !== +value) return null;\n\n  return new Expression.Binary(op, param, new Expression.Literal(v));\n}\n\nfunction queryTimestamp(param: Expression, value: string): Expression {\n  let op = \"=\";\n  for (const o of [\"<>\", \"=\", \"<=\", \"<\", \">=\", \">\"]) {\n    if (value.startsWith(o)) {\n      op = o;\n      value = value.slice(o.length).trim();\n      break;\n    }\n  }\n\n  let v = parseInt(value);\n  if (v !== +value) v = Date.parse(value);\n  if (isNaN(v)) return null;\n  return new Expression.Binary(op, param, new Expression.Literal(v));\n}\n\nfunction queryString(param: Expression, value: string): Expression {\n  return new Expression.Binary(\n    \"LIKE\",\n    new Expression.FunctionCall(\"LOWER\", [param]),\n    new Expression.Literal(value.toLowerCase()),\n  );\n}\n\nfunction queryStringCaseSensitive(\n  param: Expression,\n  value: string,\n): Expression {\n  return new Expression.Binary(\"LIKE\", param, new Expression.Literal(value));\n}\n\nfunction queryStringMonoCase(param: Expression, value: string): Expression {\n  return Expression.or(\n    new Expression.Binary(\n      \"LIKE\",\n      param,\n      new Expression.Literal(value.toLowerCase()),\n    ),\n    new Expression.Binary(\n      \"LIKE\",\n      param,\n      new Expression.Literal(value.toUpperCase()),\n    ),\n  );\n}\n\nfunction queryMac(param: Expression, value: string): Expression {\n  value = value.replace(/[^a-f0-9]/gi, \"\").toLowerCase();\n  if (!value) return null;\n  if (value.length === 12) {\n    value = value.replace(/(..)(?!$)/g, \"$1:\");\n    return Expression.or(\n      new Expression.Binary(\n        \"=\",\n        param,\n        new Expression.Literal(value.toLowerCase()),\n      ),\n      new Expression.Binary(\n        \"=\",\n        param,\n        new Expression.Literal(value.toUpperCase()),\n      ),\n    );\n  }\n\n  param = new Expression.FunctionCall(\"LOWER\", [param]);\n  return Expression.or(\n    new Expression.Binary(\n      \"LIKE\",\n      param,\n      new Expression.Literal(`%${value.replace(/(..)(?!$)/g, \"$1:\")}%`),\n    ),\n    new Expression.Binary(\n      \"LIKE\",\n      param,\n      new Expression.Literal(`%${value.replace(/(.)(.)/g, \"$1:$2\")}%`),\n    ),\n  );\n}\n\nfunction queryMacWildcard(param: Expression, value: string): Expression {\n  if (!/^[a-f0-9%]+$/i.test(value)) return queryStringMonoCase(param, value);\n  const parts = value.split(\"%\");\n\n  const groups = parts.map((p) => [\n    p.replace(/..(?=.)/gi, \"$&:\"),\n    p.replace(/(.)(.)/gi, \"$1:$2\"),\n  ]);\n\n  const set = new Set<string>();\n  for (let i = 0; i < 2 ** groups.length; ++i) {\n    const r = groups.map((g, j) => g[(i >> j) & 1]).join(\"%\");\n    if (/^[a-f0-9]:/i.test(r) || /:[a-f0-9]$/i.test(r)) continue;\n    set.add(r.toLocaleLowerCase());\n    set.add(r.toUpperCase());\n  }\n  if (!set.size) return queryStringMonoCase(param, value);\n\n  let res: Expression = new Expression.Literal(false);\n  for (const s of set) {\n    res = Expression.or(\n      res,\n      new Expression.Binary(\"LIKE\", param, new Expression.Literal(s)),\n    );\n  }\n\n  return res;\n}\n\nfunction queryTag(tag: string): Expression {\n  const t = encodeTag(tag);\n  return new Expression.Unary(\n    \"IS NOT NULL\",\n    new Expression.Parameter(Path.parse(`Tags.${t}`)),\n  );\n}\n\nexport function getTip(resource: string, label: string): string {\n  let tip;\n  if (resources[resource]?.[label]) {\n    const param = resources[resource][label];\n    const types =\n      resource === \"devices\" ? param[\"type\"] : param[\"type\"].split(\",\");\n\n    const tips = [];\n    for (const type of types) {\n      switch (type.trim()) {\n        case \"string\":\n          tips.push(\"case insensitive string pattern\");\n          break;\n        case \"string-casesensitive\":\n          tips.push(\"case sensitive string pattern\");\n          break;\n        case \"string-monocase\":\n          tips.push(\"case insensitive string pattern\");\n          break;\n        case \"number\":\n          tips.push(\"numeric value\");\n          break;\n        case \"timestamp\":\n          tips.push(\n            \"Unix timestamp or string in the form YYYY-MM-DDTHH:mm:ss.sssZ\",\n          );\n          break;\n        case \"mac\":\n          tips.push(\"partial case insensitive MAC address\");\n          break;\n        case \"mac-wildcard\":\n          tips.push(\"case insensitive MAC address\");\n          break;\n        case \"tag\":\n          tips.push(\"case sensitive string\");\n          break;\n      }\n    }\n\n    if (tips.length) tip = `${label}: ${tips.join(\", \")}`;\n  }\n  return tip;\n}\n\nexport function unpack(\n  resource: string,\n  label: string,\n  value: string,\n): Expression {\n  if (!resources[resource]) return null;\n  const type = resources[resource][label].type;\n  value = value.trim();\n  let res: Expression = new Expression.Literal(false);\n\n  if (type.length === 0 || type.includes(\"number\")) {\n    const q = queryNumber(resources[resource][label].parameter, value);\n    if (q) res = Expression.or(res, q);\n  }\n\n  if (type.length === 0 || type.includes(\"string\")) {\n    const q = queryString(resources[resource][label].parameter, value);\n    if (q) res = Expression.or(res, q);\n  }\n\n  if (type.length === 0 || type.includes(\"timestamp\")) {\n    const q = queryTimestamp(resources[resource][label].parameter, value);\n    if (q) res = Expression.or(res, q);\n  }\n\n  if (type.includes(\"string-casesensitive\")) {\n    const q = queryStringCaseSensitive(\n      resources[resource][label].parameter,\n      value,\n    );\n    if (q) res = Expression.or(res, q);\n  }\n\n  if (type.includes(\"string-monocase\")) {\n    const q = queryStringMonoCase(resources[resource][label].parameter, value);\n    if (q) res = Expression.or(res, q);\n  }\n\n  if (type.includes(\"mac\")) {\n    const q = queryMac(resources[resource][label].parameter, value);\n    if (q) res = Expression.or(res, q);\n  }\n\n  if (type.includes(\"mac-wildcard\")) {\n    const q = queryMacWildcard(resources[resource][label].parameter, value);\n    if (q) res = Expression.or(res, q);\n  }\n\n  if (type.includes(\"tag\")) {\n    const q = queryTag(value);\n    if (q) res = Expression.or(res, q);\n  }\n\n  return res;\n}\n"
  },
  {
    "path": "ui/store.ts",
    "content": "import m from \"mithril\";\nimport Expression from \"../lib/common/expression.ts\";\nimport Path from \"../lib/common/path.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport { Task } from \"../lib/types.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport { configSnapshot, genieacsVersion } from \"./config.ts\";\nimport { QueueTask } from \"./task-queue.ts\";\nimport { PingResult } from \"../lib/ping.ts\";\nimport { unionDiff } from \"../lib/common/expression/synth.ts\";\nimport {\n  bookmarkToExpression,\n  paginate,\n  toBookmark,\n} from \"../lib/common/expression/pagination.ts\";\nimport { getClockSkew } from \"./skewed-date.ts\";\n\nfunction evaluate(exp: Expression, timestamp: number): Expression;\nfunction evaluate(\n  exp: Expression,\n  timestamp: number,\n  obj: Record<string, unknown>,\n): Expression.Literal;\nfunction evaluate(\n  exp: Expression,\n  timestamp: number,\n  obj?: Record<string, unknown>,\n): Expression {\n  return exp.evaluate((e) => {\n    if (e instanceof Expression.Literal) return e;\n    else if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"NOW\") return new Expression.Literal(timestamp);\n    } else if (e instanceof Expression.Parameter && obj) {\n      let v = obj[e.path.toString()];\n      if (v == null) return new Expression.Literal(null);\n      if (typeof v === \"object\") v = v[\"value\"]?.[0];\n      return new Expression.Literal(v as any);\n    }\n    return e;\n  });\n}\nconst memoizedEvaluate = memoize(evaluate);\n\nlet fulfillTimestamp = 0;\nlet connectionNotification,\n  configNotification,\n  versionNotification,\n  skewNotification;\n\nconst queries = {\n  filter: new WeakMap(),\n  bookmark: new WeakMap(),\n  limit: new WeakMap(),\n  sort: new WeakMap(),\n  fulfilled: new WeakMap(),\n  fulfilling: new WeakSet(),\n  accessed: new WeakMap(),\n  value: new WeakMap(),\n  unsatisfied: new WeakMap(),\n};\n\ninterface Resources {\n  [resource: string]: {\n    objects: Map<string, any>;\n    count: Map<string, QueryResponse>;\n    fetch: Map<string, QueryResponse>;\n    combinedFilter: Expression;\n  };\n}\n\nconst resources: Resources = {};\nfor (const r of [\n  \"devices\",\n  \"faults\",\n  \"presets\",\n  \"provisions\",\n  \"virtualParameters\",\n  \"files\",\n  \"config\",\n  \"users\",\n  \"permissions\",\n  \"views\",\n]) {\n  resources[r] = {\n    objects: new Map(),\n    count: new Map(),\n    fetch: new Map(),\n    combinedFilter: new Expression.Literal(false) as Expression,\n  };\n}\n\nexport class QueryResponse {\n  public get fulfilled(): number {\n    queries.accessed.set(this, Date.now());\n    return queries.fulfilled.get(this) || 0;\n  }\n\n  public get fulfilling(): boolean {\n    queries.accessed.set(this, Date.now());\n    return !(queries.fulfilled.get(this) >= fulfillTimestamp);\n  }\n\n  public get value(): any {\n    queries.accessed.set(this, Date.now());\n    return queries.value.get(this);\n  }\n}\n\nfunction checkConnection(): void {\n  m.request({\n    url: \"health\",\n    method: \"GET\",\n    background: true,\n    extract: (xhr) => {\n      if (xhr.status !== 200) {\n        if (!connectionNotification) {\n          connectionNotification = notifications.push(\n            \"warning\",\n            \"Server is unreachable\",\n            {},\n          );\n        }\n        return;\n      }\n\n      if (connectionNotification) {\n        notifications.dismiss(connectionNotification);\n        connectionNotification = null;\n      }\n\n      const body = JSON.parse(xhr.responseText);\n\n      const skew = body.timestamp - Date.now();\n      const skewDrifted = Math.abs(skew - getClockSkew()) > 5000;\n      if (!skewNotification !== !skewDrifted) {\n        if (skewNotification) {\n          notifications.dismiss(skewNotification);\n          skewNotification = null;\n        } else {\n          skewNotification = notifications.push(\n            \"warning\",\n            \"Clock drift detected, please reload the page\",\n            {\n              Reload: () => {\n                window.location.reload();\n              },\n            },\n          );\n        }\n      }\n\n      const configChanged = body.configSnapshot !== configSnapshot;\n      const versionChanged = body.version !== genieacsVersion;\n\n      if (!configNotification !== !configChanged) {\n        if (configNotification) {\n          notifications.dismiss(configNotification);\n          configNotification = null;\n        } else {\n          configNotification = notifications.push(\n            \"warning\",\n            \"Configuration has been modified, please reload the page\",\n            {\n              Reload: () => {\n                window.location.reload();\n              },\n            },\n          );\n        }\n      }\n\n      if (!versionNotification !== !versionChanged) {\n        if (versionNotification) {\n          notifications.dismiss(versionNotification);\n          versionNotification = null;\n        } else {\n          versionNotification = notifications.push(\n            \"warning\",\n            \"Server has been updated, please reload the page\",\n            {\n              Reload: () => {\n                window.location.reload();\n              },\n            },\n          );\n        }\n      }\n    },\n  }).catch((err) => {\n    notifications.push(\"error\", err.message);\n  });\n}\n\nsetInterval(checkConnection, 3000);\n\nexport async function xhrRequest(\n  options: { url: string } & m.RequestOptions<unknown>,\n): Promise<any> {\n  const extract = options.extract;\n  const deserialize = options.deserialize;\n\n  options.extract = (\n    xhr: XMLHttpRequest,\n    _options?: { url: string } & m.RequestOptions<unknown>,\n  ): any => {\n    if (typeof extract === \"function\") return extract(xhr, _options);\n\n    // https://mithril.js.org/request.html#error-handling\n    if (xhr.status !== 304 && Math.floor(xhr.status / 100) !== 2) {\n      if (xhr.status === 403) throw new Error(\"Not authorized\");\n      const err = new Error();\n      err[\"message\"] =\n        xhr.status === 0\n          ? \"Server is unreachable\"\n          : `Unexpected response status code ${xhr.status}`;\n      err[\"code\"] = xhr.status;\n      err[\"response\"] = xhr.responseText;\n      throw err;\n    }\n\n    let response: any;\n    if (typeof deserialize === \"function\") {\n      response = deserialize(xhr.responseText);\n    } else if (\n      (xhr.getResponseHeader(\"content-type\") || \"\").startsWith(\n        \"application/json\",\n      )\n    ) {\n      try {\n        response = xhr.responseText ? JSON.parse(xhr.responseText) : null;\n      } catch (err) {\n        throw new Error(\"Invalid JSON: \" + xhr.responseText.slice(0, 80), {\n          cause: err,\n        });\n      }\n    } else {\n      response = xhr.responseText;\n    }\n\n    return response;\n  };\n\n  return m.request(options);\n}\n\nexport function unpackExpression(exp: Expression): Expression {\n  return memoizedEvaluate(exp, fulfillTimestamp + getClockSkew());\n}\n\nexport function count(resourceType: string, filter: Expression): QueryResponse {\n  const filterStr = filter.toString();\n  let queryResponse = resources[resourceType].count.get(filterStr);\n  if (queryResponse) return queryResponse;\n\n  queryResponse = new QueryResponse();\n\n  resources[resourceType].count.set(filterStr, queryResponse);\n  queries.filter.set(queryResponse, filter);\n  return queryResponse;\n}\n\nfunction compareFunction(sort: {\n  [param: string]: number;\n}): (a: any, b: any) => number {\n  return (a, b) => {\n    for (const [param, asc] of Object.entries(sort)) {\n      let v1 = a[param];\n      let v2 = b[param];\n      if (v1 != null && typeof v1 === \"object\") {\n        if (v1.value) v1 = v1.value[0];\n        else v1 = null;\n      }\n\n      if (v2 != null && typeof v2 === \"object\") {\n        if (v2.value) v2 = v2.value[0];\n        else v2 = null;\n      }\n\n      if (v1 > v2) {\n        return asc;\n      } else if (v1 < v2) {\n        return asc * -1;\n      } else if (v1 !== v2) {\n        const w = {\n          null: 1,\n          number: 2,\n          string: 3,\n        };\n        const w1 = w[v1 == null ? \"null\" : typeof v1] || 4;\n        const w2 = w[v2 == null ? \"null\" : typeof v2] || 4;\n        return Math.max(-1, Math.min(1, w1 - w2)) * asc;\n      }\n    }\n    return 0;\n  };\n}\n\nfunction findMatches(resourceType, filter, sort, limit): any[] {\n  let value = [];\n  for (const obj of resources[resourceType].objects.values())\n    if (evaluate(filter, fulfillTimestamp + getClockSkew(), obj).value)\n      value.push(obj);\n\n  value = value.sort(compareFunction(sort));\n  if (limit) value = value.slice(0, limit);\n\n  return value;\n}\n\nexport function fetch(\n  resourceType: string,\n  filter: Expression,\n  options: { limit?: number; sort?: { [param: string]: number } } = {},\n): QueryResponse {\n  const sort = Object.assign({}, options.sort);\n\n  const limit = options.limit || 0;\n  if (resourceType === \"devices\")\n    sort[\"DeviceID.ID\"] = sort[\"DeviceID.ID\"] || 1;\n  else sort[\"_id\"] = sort[\"_id\"] || 1;\n\n  const key = `${filter.toString()}:${limit}:${JSON.stringify(sort)}`;\n  let queryResponse = resources[resourceType].fetch.get(key);\n  if (queryResponse) return queryResponse;\n\n  queryResponse = new QueryResponse();\n  resources[resourceType].fetch.set(key, queryResponse);\n  queries.filter.set(queryResponse, filter);\n  queries.limit.set(queryResponse, limit);\n  queries.sort.set(queryResponse, sort);\n  const [satisfied, diff] = paginate(\n    resources[resourceType].combinedFilter,\n    unpackExpression(filter),\n    sort,\n  );\n  const matches = findMatches(resourceType, satisfied, sort, limit);\n  queries.value.set(queryResponse, matches);\n  if (\n    (diff instanceof Expression.Literal && !diff.value) ||\n    (limit && matches.length >= limit)\n  )\n    queries.fulfilled.set(queryResponse, fulfillTimestamp);\n  else queries.unsatisfied.set(queryResponse, diff);\n  return queryResponse;\n}\n\nexport function fulfill(accessTimestamp: number): void {\n  const allPromises = [];\n\n  for (const [resourceType, resource] of Object.entries(resources)) {\n    for (const [queryResponseKey, queryResponse] of resource.count) {\n      if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) {\n        resource.count.delete(queryResponseKey);\n        continue;\n      }\n\n      if (queries.fulfilling.has(queryResponse)) continue;\n\n      if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) {\n        queries.fulfilling.add(queryResponse);\n        let filter = queries.filter.get(queryResponse);\n        filter = unpackExpression(filter);\n        allPromises.push(\n          xhrRequest({\n            method: \"HEAD\",\n            url:\n              `api/${resourceType}/?` +\n              m.buildQueryString({\n                filter: filter.toString(),\n              }),\n            extract: (xhr) => {\n              if (xhr.status === 403) throw new Error(\"Not authorized\");\n              if (!xhr.status) {\n                throw new Error(\"Server is unreachable\");\n              } else if (xhr.status !== 200) {\n                throw new Error(\n                  `Unexpected response status code ${xhr.status}`,\n                );\n              }\n              return +xhr.getResponseHeader(\"x-total-count\");\n            },\n            background: false,\n          }).then((c) => {\n            queries.value.set(queryResponse, c);\n            queries.fulfilled.set(queryResponse, fulfillTimestamp);\n            queries.fulfilling.delete(queryResponse);\n          }),\n        );\n      }\n    }\n  }\n\n  const toFetchAll: { [resourceType: string]: QueryResponse[] } = {};\n\n  for (const [resourceType, resource] of Object.entries(resources)) {\n    for (const [queryResponseKey, queryResponse] of resource.fetch) {\n      if (!(queries.accessed.get(queryResponse) >= accessTimestamp)) {\n        resource.fetch.delete(queryResponseKey);\n        continue;\n      }\n\n      if (queries.fulfilling.has(queryResponse)) continue;\n\n      if (!(fulfillTimestamp <= queries.fulfilled.get(queryResponse))) {\n        queries.fulfilling.add(queryResponse);\n        toFetchAll[resourceType] = toFetchAll[resourceType] || [];\n        toFetchAll[resourceType].push(queryResponse);\n        let limit = queries.limit.get(queryResponse);\n        const sort = queries.sort.get(queryResponse);\n        if (limit) {\n          let filter = queries.filter.get(queryResponse);\n          filter = unpackExpression(filter);\n\n          const unsatisfied = queries.unsatisfied.get(queryResponse);\n          if (unsatisfied) {\n            limit -= queries.value.get(queryResponse).length;\n            filter = unsatisfied;\n          }\n\n          allPromises.push(\n            xhrRequest({\n              method: \"GET\",\n              url:\n                `api/${resourceType}/?` +\n                m.buildQueryString({\n                  filter: filter.toString(),\n                  limit: 1,\n                  skip: limit - 1,\n                  sort: JSON.stringify(sort),\n                  projection: Object.keys(sort).join(\",\"),\n                }),\n              background: true,\n            }).then((res) => {\n              queries.unsatisfied.delete(queryResponse);\n              if ((res as any[]).length) {\n                queries.bookmark.set(queryResponse, toBookmark(sort, res[0]));\n              } else {\n                queries.bookmark.delete(queryResponse);\n              }\n            }),\n          );\n        }\n      }\n    }\n  }\n\n  Promise.all(allPromises)\n    .then(() => {\n      let updated = false;\n      const allPromises2 = [];\n      for (const [resourceType, toFetch] of Object.entries(toFetchAll)) {\n        let combinedFilter = new Expression.Literal(false) as Expression;\n\n        for (const queryResponse of toFetch) {\n          let filter = queries.filter.get(queryResponse);\n          filter = memoizedEvaluate(filter, fulfillTimestamp + getClockSkew());\n          const bookmark = queries.bookmark.get(queryResponse);\n          const sort = queries.sort.get(queryResponse);\n          if (bookmark)\n            filter = Expression.and(\n              filter,\n              bookmarkToExpression(bookmark, sort),\n            );\n          combinedFilter = Expression.or(combinedFilter, filter);\n        }\n\n        const [union, diff] = unionDiff(\n          resources[resourceType].combinedFilter,\n          combinedFilter,\n        );\n\n        if (diff instanceof Expression.Literal && !diff.value) {\n          for (const queryResponse of toFetch) {\n            let filter = queries.filter.get(queryResponse);\n            filter = memoizedEvaluate(\n              filter,\n              fulfillTimestamp + getClockSkew(),\n            );\n            const limit = queries.limit.get(queryResponse);\n            const bookmark = queries.bookmark.get(queryResponse);\n            const sort = queries.sort.get(queryResponse);\n            if (bookmark)\n              filter = Expression.and(\n                filter,\n                bookmarkToExpression(bookmark, sort),\n              );\n\n            queries.value.set(\n              queryResponse,\n              findMatches(resourceType, filter, sort, limit),\n            );\n            queries.fulfilled.set(queryResponse, fulfillTimestamp);\n            queries.fulfilling.delete(queryResponse);\n            updated = true;\n          }\n          continue;\n        }\n\n        let deleted = new Set<string>();\n        const cf = resources[resourceType].combinedFilter;\n        if (cf instanceof Expression.Literal && !cf.value)\n          deleted = new Set(resources[resourceType].objects.keys());\n\n        const combinedFilterDiff = diff;\n        resources[resourceType].combinedFilter = union;\n\n        allPromises2.push(\n          xhrRequest({\n            method: \"GET\",\n            url:\n              `api/${resourceType}/?` +\n              m.buildQueryString({\n                filter: combinedFilterDiff.toString(),\n              }),\n            background: false,\n          }).then((res) => {\n            for (const r of res as any[]) {\n              const id = r[\"DeviceID.ID\"] ?? r[\"_id\"];\n              resources[resourceType].objects.set(id, r);\n              deleted.delete(id);\n            }\n\n            for (const d of deleted) {\n              const obj = resources[resourceType].objects.get(d);\n              if (\n                evaluate(\n                  combinedFilterDiff,\n                  fulfillTimestamp + getClockSkew(),\n                  obj,\n                ).value\n              )\n                resources[resourceType].objects.delete(d);\n            }\n\n            for (const queryResponse of toFetch) {\n              let filter = queries.filter.get(queryResponse);\n              filter = unpackExpression(filter);\n              const limit = queries.limit.get(queryResponse);\n              const bookmark = queries.bookmark.get(queryResponse);\n              const sort = queries.sort.get(queryResponse);\n              if (bookmark)\n                filter = Expression.and(\n                  filter,\n                  bookmarkToExpression(bookmark, sort),\n                );\n\n              queries.value.set(\n                queryResponse,\n                findMatches(resourceType, filter, sort, limit),\n              );\n              queries.fulfilled.set(queryResponse, fulfillTimestamp);\n              queries.fulfilling.delete(queryResponse);\n            }\n          }),\n        );\n      }\n      if (updated) m.redraw();\n      return Promise.all(allPromises2);\n    })\n    .catch((err) => {\n      notifications.push(\"error\", err.message);\n    });\n}\n\nexport function getTimestamp(): number {\n  return fulfillTimestamp;\n}\n\nexport function setTimestamp(t: number): void {\n  if (t > fulfillTimestamp) {\n    fulfillTimestamp = t;\n    for (const resource of Object.values(resources))\n      resource.combinedFilter = new Expression.Literal(false);\n  }\n}\n\nexport function postTasks(\n  deviceId: string,\n  tasks: QueueTask[],\n): Promise<string> {\n  const tasks2: Task[] = [];\n  for (const t of tasks) {\n    t.status = \"pending\";\n    const t2 = Object.assign({}, t);\n    delete t2.device;\n    delete t2.status;\n    tasks2.push(t2);\n  }\n\n  return xhrRequest({\n    method: \"POST\",\n    url: `api/devices/${encodeURIComponent(deviceId)}/tasks`,\n    body: tasks2,\n    extract: (xhr) => {\n      if (xhr.status === 403) throw new Error(\"Not authorized\");\n      if (!xhr.status) throw new Error(\"Server is unreachable\");\n      if (xhr.status !== 200) throw new Error(xhr.response);\n      const connectionRequestStatus =\n        xhr.getResponseHeader(\"Connection-Request\");\n      const st = JSON.parse(xhr.response);\n      for (const [i, t] of st.entries()) {\n        tasks[i]._id = t._id;\n        tasks[i].status = t.status;\n      }\n      return connectionRequestStatus;\n    },\n  });\n}\n\nexport function updateTags(\n  deviceId: string,\n  tags: Record<string, boolean>,\n): Promise<void> {\n  return xhrRequest({\n    method: \"POST\",\n    url: `api/devices/${encodeURIComponent(deviceId)}/tags`,\n    body: tags,\n  });\n}\n\nexport function deleteResource(\n  resourceType: string,\n  id: string,\n): Promise<void> {\n  return xhrRequest({\n    method: \"DELETE\",\n    url: `api/${resourceType}/${encodeURIComponent(id)}`,\n  });\n}\n\nexport function putResource(\n  resourceType: string,\n  id: string,\n  object: Record<string, unknown>,\n): Promise<void> {\n  for (const k in object) if (object[k] === undefined) object[k] = null;\n\n  return xhrRequest({\n    method: \"PUT\",\n    url: `api/${resourceType}/${encodeURIComponent(id)}`,\n    body: object,\n  });\n}\n\nexport function queryConfig(pattern = \"%\"): Promise<any[]> {\n  const filter = new Expression.Binary(\n    \"LIKE\",\n    new Expression.Parameter(Path.parse(\"_id\")),\n    new Expression.Literal(pattern),\n  );\n  return xhrRequest({\n    method: \"GET\",\n    url: `api/config/?${m.buildQueryString({ filter: filter.toString() })}`,\n    background: true,\n  });\n}\n\nexport function resourceExists(resource: string, id: string): Promise<number> {\n  const param = resource === \"devices\" ? \"DeviceID.ID\" : \"_id\";\n  const filter = new Expression.Binary(\n    \"=\",\n    new Expression.Parameter(Path.parse(param)),\n    new Expression.Literal(id),\n  );\n\n  return xhrRequest({\n    method: \"HEAD\",\n    url:\n      `api/${resource}/?` +\n      m.buildQueryString({\n        filter: filter.toString(),\n      }),\n    extract: (xhr) => {\n      if (xhr.status === 403) throw new Error(\"Not authorized\");\n      if (!xhr.status) throw new Error(\"Server is unreachable\");\n      else if (xhr.status !== 200)\n        throw new Error(`Unexpected response status code ${xhr.status}`);\n      return +xhr.getResponseHeader(\"x-total-count\");\n    },\n    background: true,\n  });\n}\n\nexport function evaluateExpression(exp: Expression): Expression;\nexport function evaluateExpression(\n  exp: Expression,\n  obj: Record<string, unknown>,\n): Expression.Literal;\nexport function evaluateExpression(\n  exp: Expression,\n  obj?: Record<string, unknown>,\n): Expression {\n  return memoizedEvaluate(exp, fulfillTimestamp + getClockSkew(), obj);\n}\n\nexport function changePassword(\n  username: string,\n  newPassword: string,\n  authPassword?: string,\n): Promise<void> {\n  const body = { newPassword };\n  if (authPassword) body[\"authPassword\"] = authPassword;\n  return xhrRequest({\n    method: \"PUT\",\n    url: `api/users/${username}/password`,\n    background: true,\n    body,\n  });\n}\n\nexport function logIn(\n  username: string,\n  password: string,\n  remember = false,\n): Promise<void> {\n  return xhrRequest({\n    method: \"POST\",\n    url: \"login\",\n    background: true,\n    body: { username, password, remember },\n  });\n}\n\nexport function logOut(): Promise<void> {\n  return xhrRequest({\n    method: \"POST\",\n    url: \"logout\",\n  });\n}\n\nexport function ping(host: string): Promise<PingResult> {\n  return xhrRequest({\n    url: `api/ping/${encodeURIComponent(host)}`,\n    background: true,\n  });\n}\n"
  },
  {
    "path": "ui/tailwind-utility-components.ts",
    "content": "import m, {\n  ChildArrayOrPrimitive,\n  ClosureComponent,\n  mount,\n  Vnode,\n  VnodeDOM,\n} from \"mithril\";\nimport { ICONS_SVG } from \"../build/assets.ts\";\n\nexport const portal: ClosureComponent<void> = () => {\n  let rootElement: HTMLElement;\n  let children: ChildArrayOrPrimitive;\n\n  return {\n    oncreate: (vnode) => {\n      children = vnode.children;\n      rootElement = document.createElement(\"div\");\n      document.body.appendChild(rootElement);\n      mount(rootElement, { view: () => children });\n    },\n\n    onupdate: (vnode) => {\n      children = vnode.children;\n    },\n\n    onremove: () => {\n      if (document.body.contains(rootElement)) {\n        mount(rootElement, null);\n        document.body.removeChild(rootElement);\n      }\n    },\n\n    view: () => {\n      return null;\n    },\n  };\n};\n\ninterface DialogAttrs {\n  onClose?: () => void;\n  as: string;\n  class?: string;\n}\n\nexport const dialog: ClosureComponent<DialogAttrs> = () => {\n  return {\n    view(vnode) {\n      return m(\n        portal,\n        m(\n          vnode.attrs.as,\n          { class: vnode.attrs.class, role: \"dialog\", \"aria-modal\": \"true\" },\n          vnode.children,\n        ),\n      );\n    },\n  };\n};\n\ninterface DialogOverlayAttrs {\n  class?: string;\n}\n\nexport const dialogOverlay: ClosureComponent<DialogOverlayAttrs> = () => {\n  return {\n    view(vnode) {\n      return m(\n        \"div\",\n        { class: vnode.attrs.class, \"aria-hidden\": \"true\" },\n        vnode.children,\n      );\n    },\n  };\n};\n\nlet transitionShow = false;\nlet transitionTimeout = 0;\n\ninterface TransitionRootAttrs {\n  show: boolean;\n  duration: number;\n}\n\nexport const transitionRoot: ClosureComponent<TransitionRootAttrs> = (\n  initialVnode,\n) => {\n  let show = initialVnode.attrs.show;\n  let transitionTimestamp = 0;\n  let timeout: ReturnType<typeof setTimeout>;\n\n  return {\n    view(vnode: Vnode<TransitionRootAttrs>) {\n      if (show !== vnode.attrs.show) {\n        const now = Date.now();\n        transitionTimestamp = now;\n        show = vnode.attrs.show;\n        clearTimeout(timeout);\n        timeout = setTimeout(() => m.redraw(), vnode.attrs.duration);\n      }\n\n      transitionShow = show;\n      transitionTimeout = Math.max(\n        0,\n        vnode.attrs.duration - (Date.now() - transitionTimestamp),\n      );\n\n      if (!transitionShow && !transitionTimeout) return null;\n\n      return vnode.children;\n    },\n  };\n};\n\ninterface TransitionChildAttrs {\n  enter: string;\n  enterFrom: string;\n  enterTo: string;\n  leave: string;\n  leaveFrom: string;\n  leaveTo: string;\n}\n\nexport const transitionChild: ClosureComponent<TransitionChildAttrs> = () => {\n  let show: boolean;\n  let timeout: number;\n  let firstFrame: boolean;\n\n  function updateCssClasses(vnode: VnodeDOM<TransitionChildAttrs>): void {\n    const dom = vnode.dom as HTMLElement;\n    const enter = vnode.attrs.enter.split(\" \");\n    const enterFrom = vnode.attrs.enterFrom.split(\" \");\n    const enterTo = vnode.attrs.enterTo.split(\" \");\n    const leave = vnode.attrs.leave.split(\" \");\n    const leaveFrom = vnode.attrs.leaveFrom.split(\" \");\n    const leaveTo = vnode.attrs.leaveTo.split(\" \");\n\n    if (show) {\n      dom.classList.remove(...leave, ...leaveFrom, ...leaveTo);\n      if (firstFrame) {\n        dom.classList.add(...enterFrom);\n        void dom.getBoundingClientRect();\n        dom.classList.remove(...enterFrom);\n      }\n      if (timeout) dom.classList.add(...enter);\n      else dom.classList.remove(...enter);\n      dom.classList.add(...enterTo);\n    } else {\n      dom.classList.remove(...enter, ...enterFrom, ...enterTo);\n      if (firstFrame) {\n        dom.classList.add(...leaveFrom);\n        void dom.getBoundingClientRect();\n        dom.classList.remove(...leaveFrom);\n      }\n      if (timeout) dom.classList.add(...leave);\n      else dom.classList.remove(...leave);\n      dom.classList.add(...leaveTo);\n    }\n  }\n\n  return {\n    view(vnode: Vnode<TransitionChildAttrs>) {\n      firstFrame = show !== transitionShow;\n      show = transitionShow;\n      timeout = transitionTimeout;\n      return vnode.children;\n    },\n\n    oncreate(vnode: VnodeDOM<TransitionChildAttrs>) {\n      updateCssClasses(vnode);\n    },\n\n    onupdate(vnode: VnodeDOM<TransitionChildAttrs>) {\n      updateCssClasses(vnode);\n    },\n  };\n};\n\ninterface IconAttrs {\n  name: string;\n  class?: string;\n}\n\nexport const icon: ClosureComponent<IconAttrs> = () => {\n  return {\n    view(vnode) {\n      return m(\n        `svg`,\n        {\n          xmlns: \"http://www.w3.org/2000/svg\",\n          fill: \"none\",\n          stroke: \"currentColor\",\n          \"stroke-width\": \"2\",\n          class: vnode.attrs.class,\n          \"aria-hidden\": \"true\",\n        },\n        m(\"use\", { href: `${ICONS_SVG}#icon-${vnode.attrs.name}` }),\n      );\n    },\n  };\n};\n"
  },
  {
    "path": "ui/task-queue.ts",
    "content": "import m from \"mithril\";\nimport * as store from \"./store.ts\";\nimport { Task } from \"../lib/types.ts\";\nimport * as notifications from \"./notifications.ts\";\n\nexport interface QueueTask extends Task {\n  status?: string;\n  device: string;\n}\n\nexport interface StageTask extends Task {\n  devices: string[];\n}\n\nconst MAX_QUEUE = 100;\n\nconst queue: Set<QueueTask> = new Set();\nconst staging: Set<StageTask> = new Set();\n\nfunction canQueue(tasks: QueueTask[]): boolean {\n  let count = queue.size;\n  for (const task of tasks) if (!queue.has(task)) ++count;\n  return count <= MAX_QUEUE;\n}\n\nexport function queueTask(...tasks: QueueTask[]): void {\n  if (!canQueue(tasks)) {\n    notifications.push(\"error\", \"Too many tasks in queue\");\n    return;\n  }\n\n  for (const task of tasks) {\n    task.status = \"queued\";\n    queue.add(task);\n  }\n  m.redraw();\n}\n\nexport function deleteTask(task: QueueTask): void {\n  queue.delete(task);\n}\n\nexport function getQueue(): Set<QueueTask> {\n  return queue;\n}\n\nexport function clear(): void {\n  queue.clear();\n}\n\nexport function getStaging(): Set<StageTask> {\n  return staging;\n}\n\nexport function clearStaging(): void {\n  staging.clear();\n}\n\nexport function stageSpv(task: StageTask): void {\n  if (queue.size + task.devices.length > MAX_QUEUE) {\n    notifications.push(\"error\", \"Too many tasks in queue\");\n    return;\n  }\n  staging.add(task);\n  m.redraw();\n}\n\nexport function stageDownload(task: StageTask): void {\n  if (queue.size + task.devices.length > MAX_QUEUE) {\n    notifications.push(\"error\", \"Too many tasks in queue\");\n    return;\n  }\n  staging.add(task);\n  m.redraw();\n}\n\nexport function commit(\n  tasks: QueueTask[],\n  callback: (\n    deviceId: string,\n    err: Error,\n    conReqStatus: string,\n    _tasks: QueueTask[],\n  ) => void,\n): Promise<void> {\n  const devices: { [deviceId: string]: QueueTask[] } = {};\n\n  if (!canQueue(tasks))\n    return Promise.reject(new Error(\"Too many tasks in queue\"));\n\n  for (const t of tasks) {\n    devices[t.device] = devices[t.device] || [];\n    devices[t.device].push(t);\n    t.status = \"queued\";\n    queue.add(t);\n  }\n\n  return new Promise((resolve) => {\n    let counter = 1;\n    for (const [deviceId, tasks2] of Object.entries(devices)) {\n      ++counter;\n      store\n        .postTasks(deviceId, tasks2)\n        .then((connectionRequestStatus) => {\n          for (const t of tasks2) {\n            if (t.status === \"pending\") t.status = \"stale\";\n            else if (t.status === \"done\") queue.delete(t);\n          }\n          callback(deviceId, null, connectionRequestStatus, tasks2);\n          if (--counter === 0) resolve();\n        })\n        .catch((err) => {\n          for (const t of tasks2) t.status = \"stale\";\n          callback(deviceId, err, null, tasks2);\n          if (--counter === 0) resolve();\n        });\n    }\n\n    if (--counter === 0) resolve();\n  });\n}\n"
  },
  {
    "path": "ui/timeago.ts",
    "content": "const UNITS = {\n  year: 12 * 30 * 24 * 60 * 60 * 1000,\n  month: 30 * 24 * 60 * 60 * 1000,\n  day: 24 * 60 * 60 * 1000,\n  hour: 60 * 60 * 1000,\n  minute: 60 * 1000,\n  second: 1000,\n};\n\nexport default function timeAgo(dtime: number): string {\n  let res = \"\";\n  let level = 2;\n\n  for (const [u, t] of Object.entries(UNITS)) {\n    if (dtime >= t) {\n      let n;\n      if (level > 1) {\n        n = Math.floor(dtime / t);\n        dtime -= n * t;\n      } else {\n        n = Math.round(dtime / t);\n      }\n      if (n > 1) res += `${n} ${u}s `;\n      else res += `${n} ${u} `;\n      if (!--level) break;\n    }\n  }\n\n  return res + \"ago\";\n}\n"
  },
  {
    "path": "ui/ui-config-component.ts",
    "content": "import { ClosureComponent } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport * as store from \"./store.ts\";\nimport { yaml } from \"./dynamic-loader.ts\";\nimport * as configFunctions from \"./config-functions.ts\";\nimport codeEditorComponent from \"./code-editor-component.ts\";\nimport Expression from \"../lib/common/expression.ts\";\n\nfunction putActionHandler(prefix: string[], dataYaml: string): Promise<any> {\n  return new Promise((resolve, reject) => {\n    try {\n      let updated = yaml.parse(dataYaml, { schema: \"failsafe\" });\n      if (updated) {\n        const config = {};\n        let ref = config;\n        prefix.forEach((seg, index) => {\n          if (index < prefix.length - 1) {\n            ref[seg] = {};\n            ref = ref[seg];\n          } else {\n            ref[seg] = updated;\n          }\n        });\n        updated = configFunctions.flattenConfig(config);\n      } else {\n        updated = {};\n      }\n\n      // Try parse to ensure valid expressions\n      for (const v of Object.values(updated)) Expression.parse(v as string);\n\n      store\n        .queryConfig(`${prefix.join(\".\")}.%`)\n        .then((res) => {\n          const current = {};\n          for (const f of res) current[f._id] = f.value;\n\n          const diff = configFunctions.diffConfig(current, updated);\n          if (!diff.add.length && !diff.remove.length)\n            return void resolve(null);\n\n          const promises = [];\n\n          for (const obj of diff.add) {\n            promises.push(\n              store.putResource(\n                \"config\",\n                obj._id,\n                obj as unknown as Record<string, unknown>,\n              ),\n            );\n          }\n\n          for (const id of diff.remove)\n            promises.push(store.deleteResource(\"config\", id));\n\n          Promise.all(promises)\n            .then(() => {\n              resolve(null);\n            })\n            .catch(reject);\n        })\n        .catch(reject);\n    } catch (error) {\n      resolve({ config: error.message });\n    }\n  });\n}\n\ninterface Attrs {\n  prefix: string;\n  name: string;\n  data: { _id: string; value: string }[];\n  onUpdate: (errs: Record<string, string>) => void;\n  onError: (err: Error) => void;\n}\n\nconst component: ClosureComponent<Attrs> = () => {\n  return {\n    view: (vnode) => {\n      const prefix = vnode.attrs.prefix.split(\".\");\n      const name = vnode.attrs.name;\n      const data = vnode.attrs.data;\n\n      if (prefix[prefix.length - 1] === \"\") prefix.pop();\n\n      let config;\n      if (data.length) {\n        config = configFunctions.structureConfig(data);\n        for (const seg of prefix) config = config[seg];\n      }\n\n      const yamlString =\n        config && Object.values(config).length\n          ? yaml.stringify(config, { schema: \"failsafe\" })\n          : \"\";\n\n      const attrs = {\n        id: `${name}-ui-config`,\n        value: yamlString,\n        mode: \"yaml\",\n        focus: true,\n        onSubmit: (dom) => {\n          dom.form.querySelector(\"button[type=submit]\").click();\n        },\n        onChange: (value) => {\n          vnode.state[\"updatedYaml\"] = value;\n          vnode.state[\"modified\"] = true;\n        },\n      };\n\n      const code = m(codeEditorComponent, attrs);\n      const submit = m(\n        \"button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500\",\n        { type: \"submit\" },\n        \"Save\",\n      );\n\n      return m(\"div\", [\n        m(\n          \"h2.mb-5 text-lg leading-6 font-medium text-stone-900\",\n          `Editing ${name}`,\n        ),\n        m(\n          \"form\",\n          {\n            onsubmit: (e) => {\n              e.redraw = false;\n              e.preventDefault();\n              if (vnode.state[\"updatedYaml\"] == null)\n                vnode.state[\"updatedYaml\"] = yamlString;\n\n              putActionHandler(prefix, vnode.state[\"updatedYaml\"])\n                .then(vnode.attrs.onUpdate)\n                .catch(vnode.attrs.onError);\n            },\n          },\n          [code, m(\".flex justify-end mt-5\", [submit])],\n        ),\n      ]);\n    },\n  };\n};\n\nexport default component;\n"
  },
  {
    "path": "ui/users-page.ts",
    "content": "import { Children, ClosureComponent, Component } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE } from \"./config.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport changePasswordComponent from \"./change-password-component.ts\";\n\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes = [\n  { id: \"_id\", label: \"Username\" },\n  { id: \"roles\", label: \"Roles\", type: \"multi\", options: [] },\n];\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"users\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\ninterface ValidationErrors {\n  [prop: string]: string;\n}\n\nfunction putActionHandler(action, _object, isNew): Promise<ValidationErrors> {\n  return new Promise((resolve, reject) => {\n    const object = Object.assign({}, _object);\n    if (action === \"save\") {\n      const id = object[\"_id\"];\n      const password = object[\"password\"];\n      const confirm = object[\"confirm\"];\n      delete object[\"_id\"];\n      delete object[\"password\"];\n      delete object[\"confirm\"];\n\n      if (!id) return void resolve({ _id: \"ID can not be empty\" });\n\n      if (isNew) {\n        if (!password) {\n          return void resolve({ password: \"Password can not be empty\" });\n        } else if (password !== confirm) {\n          return void resolve({\n            confirm: \"Confirm password doesn't match password\",\n          });\n        }\n      }\n\n      if (!Array.isArray(object.roles) || !object.roles.length)\n        return void resolve({ roles: \"Role(s) must be selected\" });\n\n      object.roles = object.roles.join(\",\");\n\n      store\n        .resourceExists(\"users\", id)\n        .then((exists) => {\n          if (exists && isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"User already exists\" });\n          }\n\n          if (!exists && !isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"User does not exist\" });\n          }\n\n          store\n            .putResource(\"users\", id, object)\n            .then(() => {\n              if (isNew) {\n                store\n                  .changePassword(id, password)\n                  .then(() => {\n                    notifications.push(\"success\", \"User created\");\n                    store.setTimestamp(Date.now());\n                    resolve(null);\n                  })\n                  .catch(reject);\n              } else {\n                notifications.push(\"success\", \"User updated\");\n                store.setTimestamp(Date.now());\n                resolve(null);\n              }\n            })\n            .catch(reject);\n        })\n        .catch(reject);\n    } else if (action === \"delete\") {\n      if (!confirm(\"Deleting user. Are you sure?\")) return void resolve(null);\n      store\n        .deleteResource(\"users\", object[\"_id\"])\n        .then(() => {\n          notifications.push(\"success\", \"User deleted\");\n          store.setTimestamp(Date.now());\n          resolve(null);\n        })\n        .catch((err) => {\n          store.setTimestamp(Date.now());\n          reject(err);\n        });\n    } else {\n      reject(new Error(\"Undefined action\"));\n    }\n  });\n}\n\nconst getDownloadUrl = memoize((filter) => {\n  const cols = {};\n  for (const attr of attributes) cols[attr.label] = attr.id;\n\n  return `api/users.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"users\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  let filter: Expression = null;\n  let sort: Record<string, number> = null;\n  if (args.hasOwnProperty(\"filter\"))\n    filter = Expression.parse(args[\"filter\"] as string);\n  if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n  return Promise.resolve({ filter, sort });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Users - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/users\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++) {\n        const attr = attributes[i];\n        if (attr.id !== \"roles\")\n          sortAttributes[i] = sort[attributes[i].id] || 0;\n      }\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/users\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const users = store.fetch(\"users\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n\n      const count = store.count(\"users\", filter);\n\n      // Getting the roles\n      const permissions = store.fetch(\n        \"permissions\",\n        new Expression.Literal(true),\n      );\n      if (permissions.fulfilled) {\n        for (const attr of attributes) {\n          if (attr.id === \"roles\")\n            attr.options = [...new Set(permissions.value.map((p) => p.role))];\n        }\n      }\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const canWrite = window.authorizer.hasAccess(\"users\", 3);\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = users.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n      attrs[\"recordActionsCallback\"] = (user) => {\n        return [\n          m(\n            \"button.text-cyan-700 hover:text-cyan-900 font-medium\",\n            {\n              onclick: () => {\n                let cb: () => Children = null;\n                const comp = m(\n                  putFormComponent,\n                  Object.assign(\n                    {\n                      base: {\n                        _id: user._id,\n                        roles: user.roles.split(\",\"),\n                      },\n                      actionHandler: (action, object) => {\n                        return new Promise<void>((resolve) => {\n                          putActionHandler(action, object, false)\n                            .then((errors) => {\n                              const errorList = errors\n                                ? Object.values(errors)\n                                : [];\n                              if (errorList.length) {\n                                for (const err of errorList)\n                                  notifications.push(\"error\", err);\n                              } else {\n                                overlay.close(cb);\n                              }\n                              resolve();\n                            })\n                            .catch((err) => {\n                              notifications.push(\"error\", err.message);\n                              resolve();\n                            });\n                        });\n                      },\n                    },\n                    {\n                      resource: \"users\",\n                      attributes: attributes,\n                    },\n                  ),\n                );\n\n                cb = () => {\n                  const children: Children = [comp];\n                  if (canWrite) {\n                    children.push(m(\"hr\"));\n                    const _attrs = {\n                      noAuth: true,\n                      username: user._id,\n                      onPasswordChange: () => {\n                        overlay.close(cb);\n                        m.redraw();\n                      },\n                    };\n                    children.push(m(changePasswordComponent, _attrs));\n                  }\n\n                  return children;\n                };\n\n                overlay.open(\n                  cb,\n                  () =>\n                    !comp.state[\"current\"][\"modified\"] ||\n                    confirm(\"You have unsaved changes. Close anyway?\"),\n                );\n              },\n            },\n            \"Show\",\n          ),\n        ];\n      };\n\n      if (canWrite) {\n        const formData = {\n          resource: \"users\",\n          attributes: [\n            attributes[0],\n            { id: \"password\", label: \"Password\", type: \"password\" },\n            { id: \"confirm\", label: \"Confirm password\", type: \"password\" },\n            attributes[1],\n          ],\n        };\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return [\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Create new user\",\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        actionHandler: (action, object) => {\n                          return new Promise<void>((resolve) => {\n                            putActionHandler(action, object, true)\n                              .then((errors) => {\n                                const errorList = errors\n                                  ? Object.values(errors)\n                                  : [];\n                                if (errorList.length) {\n                                  for (const err of errorList)\n                                    notifications.push(\"error\", err);\n                                } else {\n                                  overlay.close(cb);\n                                }\n                                resolve();\n                              })\n                              .catch((err) => {\n                                notifications.push(\"error\", err.message);\n                                resolve();\n                              });\n                          });\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"current\"][\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              \"New\",\n            ),\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Delete selected users\",\n                disabled: !selected.size,\n                onclick: (e) => {\n                  if (\n                    !confirm(`Deleting ${selected.size} users. Are you sure?`)\n                  )\n                    return;\n\n                  e.redraw = false;\n                  e.target.disabled = true;\n                  Promise.all(\n                    Array.from(selected).map((id) =>\n                      store.deleteResource(\"users\", id),\n                    ),\n                  )\n                    .then((res) => {\n                      notifications.push(\n                        \"success\",\n                        `${res.length} users deleted`,\n                      );\n                      store.setTimestamp(Date.now());\n                    })\n                    .catch((err) => {\n                      notifications.push(\"error\", err.message);\n                      store.setTimestamp(Date.now());\n                    });\n                },\n              },\n              \"Delete\",\n            ),\n          ];\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"users\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing users\"),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [users, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/views-bundle-placeholder.ts",
    "content": "declare module \"views-bundle\" {\n  const views: Record<string, (...args: unknown[]) => unknown>;\n  export default views;\n}\n"
  },
  {
    "path": "ui/views-page.ts",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport * as config from \"./config.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { loadCodeMirror } from \"./dynamic-loader.ts\";\n\nconst PAGE_SIZE = config.pageSize || 10;\n\nconst memoizedParse = memoize(Expression.parse);\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes = [\n  { id: \"_id\", label: \"Name\" },\n  { id: \"script\", label: \"Script\", type: \"code\", mode: \"jsx\" },\n];\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.map((e) => {\n    if (\n      e instanceof Expression.FunctionCall &&\n      e.name === \"Q\" &&\n      e.args.length >= 2\n    ) {\n      const arg0 =\n        e.args[0] instanceof Expression.Literal ? e.args[0].value : null;\n      const arg1 =\n        e.args[1] instanceof Expression.Literal ? e.args[1].value : null;\n      return smartQuery.unpack(\"views\", arg0 as string, arg1 as string);\n    }\n    return e;\n  });\n});\n\ninterface ValidationErrors {\n  [prop: string]: string;\n}\n\nfunction putActionHandler(action, _object, isNew): Promise<ValidationErrors> {\n  return new Promise((resolve, reject) => {\n    const object = Object.assign({}, _object);\n    if (action === \"save\") {\n      const id = object[\"_id\"];\n      delete object[\"_id\"];\n\n      if (!id) return void resolve({ _id: \"ID can not be empty\" });\n\n      store\n        .resourceExists(\"views\", id)\n        .then((exists) => {\n          if (exists && isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"View already exists\" });\n          }\n\n          if (!exists && !isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"View does not exist\" });\n          }\n\n          store\n            .putResource(\"views\", id, object)\n            .then(() => {\n              notifications.push(\n                \"success\",\n                `View ${exists ? \"updated\" : \"created\"}`,\n              );\n              store.setTimestamp(Date.now());\n              resolve(null);\n            })\n            .catch((err) => {\n              if (err[\"code\"] === 400 && err[\"response\"]) {\n                reject(new Error(err[\"response\"]));\n                return;\n              }\n              reject(err);\n            });\n        })\n        .catch(reject);\n    } else if (action === \"delete\") {\n      if (!confirm(\"Deleting view. Are you sure?\")) return void resolve(null);\n      store\n        .deleteResource(\"views\", object[\"_id\"])\n        .then(() => {\n          notifications.push(\"success\", \"View deleted\");\n          store.setTimestamp(Date.now());\n          resolve(null);\n        })\n        .catch((err) => {\n          store.setTimestamp(Date.now());\n          reject(err);\n        });\n    } else {\n      reject(new Error(\"Undefined action\"));\n    }\n  });\n}\n\nconst formData = {\n  resource: \"views\",\n  attributes: attributes,\n};\n\nconst getDownloadUrl = memoize((filter: Expression) => {\n  const cols = {};\n  for (const attr of attributes) cols[attr.label] = attr.id;\n  return `api/views.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"views\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  const sort = args.hasOwnProperty(\"sort\") ? \"\" + args[\"sort\"] : \"\";\n  const filter = args.hasOwnProperty(\"filter\") ? \"\" + args[\"filter\"] : \"\";\n\n  return new Promise((resolve, reject) => {\n    loadCodeMirror()\n      .then(() => {\n        resolve({ filter, sort });\n      })\n      .catch(reject);\n  });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Views - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = { filter };\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/views\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++)\n        sortAttributes[i] = sort[attributes[i].id] || 0;\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/views\", ops);\n      }\n\n      let filter: Expression = vnode.attrs[\"filter\"]\n        ? memoizedParse(vnode.attrs[\"filter\"])\n        : new Expression.Literal(true);\n      filter = unpackSmartQuery(filter);\n\n      const views = store.fetch(\"views\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n\n      const count = store.count(\"views\", filter);\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = views.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n      attrs[\"recordActionsCallback\"] = (cmp) => {\n        return [\n          m(\n            \"button.text-cyan-700 hover:text-cyan-900 font-medium\",\n            {\n              onclick: () => {\n                let cb: () => Children = null;\n                const comp = m(\n                  putFormComponent,\n                  Object.assign(\n                    {\n                      base: cmp,\n                      actionHandler: (action, object) => {\n                        return new Promise<void>((resolve) => {\n                          putActionHandler(action, object, false)\n                            .then((errors) => {\n                              const errorList = errors\n                                ? Object.values(errors)\n                                : [];\n                              if (errorList.length) {\n                                for (const err of errorList)\n                                  notifications.push(\"error\", err);\n                              } else {\n                                overlay.close(cb);\n                              }\n                              resolve();\n                            })\n                            .catch((err) => {\n                              notifications.push(\"error\", err.message);\n                              resolve();\n                            });\n                        });\n                      },\n                    },\n                    formData,\n                  ),\n                );\n                cb = () => comp;\n                overlay.open(\n                  cb,\n                  () =>\n                    !comp.state[\"current\"][\"modified\"] ||\n                    confirm(\"You have unsaved changes. Close anyway?\"),\n                );\n              },\n            },\n            \"Show\",\n          ),\n        ];\n      };\n\n      if (window.authorizer.hasAccess(\"views\", 3)) {\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return [\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-sm text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Create new view\",\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        actionHandler: (action, object) => {\n                          return new Promise<void>((resolve) => {\n                            putActionHandler(action, object, true)\n                              .then((errors) => {\n                                const errorList = errors\n                                  ? Object.values(errors)\n                                  : [];\n                                if (errorList.length) {\n                                  for (const err of errorList)\n                                    notifications.push(\"error\", err);\n                                } else {\n                                  overlay.close(cb);\n                                }\n                                resolve();\n                              })\n                              .catch((err) => {\n                                notifications.push(\"error\", err.message);\n                                resolve();\n                              });\n                          });\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"current\"][\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              \"New\",\n            ),\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-sm text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Delete selected views\",\n                disabled: !selected.size,\n                onclick: (e) => {\n                  if (\n                    !confirm(`Deleting ${selected.size} views. Are you sure?`)\n                  )\n                    return;\n\n                  e.redraw = false;\n                  e.target.disabled = true;\n                  Promise.all(\n                    Array.from(selected).map((id) =>\n                      store.deleteResource(\"views\", id),\n                    ),\n                  )\n                    .then((res) => {\n                      notifications.push(\n                        \"success\",\n                        `${res.length} views deleted`,\n                      );\n                      store.setTimestamp(Date.now());\n                    })\n                    .catch((err) => {\n                      notifications.push(\"error\", err.message);\n                      store.setTimestamp(Date.now());\n                    });\n                },\n              },\n              \"Delete\",\n            ),\n          ];\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"views\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\"h1.text-xl font-medium text-stone-900 mb-5\", \"Listing views\"),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [views, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/views.ts",
    "content": "import m, { ClosureComponent, ChildArray } from \"mithril\";\nimport {\n  ComputedSignal,\n  ConstSignal,\n  SignalBase,\n  StateSignal,\n  Watcher,\n  setTimeout as _setTimeout,\n  setInterval as _setInterval,\n} from \"./signals.ts\";\nimport views from \"views-bundle\";\nimport { count, fetch, invalidate } from \"./reactive-store.ts\";\nimport { SkewedDate, getClockSkew } from \"./skewed-date.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport * as taskQueue from \"./task-queue.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport { deleteResource, ping, updateTags } from \"./store.ts\";\nimport { stringify } from \"../lib/common/yaml.ts\";\n\ntype ViewElement =\n  | ViewNode\n  | string\n  | number\n  | SignalBase<ViewElement>\n  | ViewElement[];\n\nexport class ViewNode {\n  name: string | null;\n  attributes: Record<string, any>;\n  children: ViewElement[];\n  constructor(\n    name: string,\n    attributes: Record<string, any>,\n    children: ViewElement[],\n  ) {\n    this.name = name;\n    this.attributes = attributes ?? {};\n    this.children = children;\n  }\n}\n\n// A signalized version of ViewNode where all properties are wrapped in signals.\n// If the original value is already a Signal, it's used as-is.\n// Otherwise, a ConstSignal is created to wrap the value.\nexport interface SignalizedViewNode {\n  name: SignalBase<string | null>;\n  attributes: Record<string, SignalBase<unknown>>;\n  children: SignalBase<ViewElement>[];\n}\n\n// Wraps a value in a ConstSignal if it's not already a Signal.\nfunction toSignal<T>(value: T | SignalBase<T>): SignalBase<T> {\n  if (value instanceof SignalBase) return value;\n  return new ConstSignal(value);\n}\n\n// Converts a ViewNode to a SignalizedViewNode where all properties are signals.\nfunction signalizeNode(node: ViewNode): SignalizedViewNode {\n  const signalizedAttrs: Record<string, SignalBase<unknown>> = {};\n  for (const [key, value] of Object.entries(node.attributes)) {\n    signalizedAttrs[key] = toSignal(value);\n  }\n\n  return {\n    name: toSignal(node.name),\n    attributes: signalizedAttrs,\n    children: node.children.map((child) => toSignal(child)),\n  };\n}\n\nfunction doCount(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get() as {\n      resource: string;\n      filter: string;\n      freshness?: number;\n    } | null;\n    if (!arg) return null;\n    const res = node.attributes[\"res\"] as StateSignal<number>;\n\n    // View scripts see server-adjusted time (via SkewedDate), but cache\n    // timestamps use local time, so convert back to local time.\n    const localFreshness = arg.freshness ? arg.freshness - getClockSkew() : 0;\n    const querySignal = count(arg.resource, Expression.parse(arg.filter), {\n      freshness: localFreshness,\n    });\n\n    const sig = new ComputedSignal(() => {\n      if (res) res.set(querySignal.get().value);\n      return null;\n    });\n    return sig;\n  });\n}\n\nfunction doFetch(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get() as {\n      resource: string;\n      filter: string;\n      freshness?: number;\n    } | null;\n    if (!arg) return null;\n    const res = node.attributes[\"res\"] as StateSignal<unknown[]>;\n\n    const localFreshness = arg.freshness ? arg.freshness - getClockSkew() : 0;\n    const querySignal = fetch(arg.resource, Expression.parse(arg.filter), {\n      freshness: localFreshness,\n    });\n\n    const sig = new ComputedSignal(() => {\n      if (res) res.set(querySignal.get().value);\n      return null;\n    });\n    return sig;\n  });\n}\n\nfunction doTask(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get() as {\n      name: string;\n      device: string;\n      commit?: boolean;\n      parameterNames?: string[];\n      parameterValues?: unknown[];\n      objectName?: string;\n    } | null;\n    if (!arg) return null;\n    const res = node.attributes[\"res\"] as StateSignal<string>;\n    const task: any = Object.assign({}, arg);\n    if (arg.commit) {\n      if (res) res.set(\"pending\");\n      taskQueue\n        .commit([task], (_, err, conReq, tasks2) => {\n          for (const t of tasks2)\n            if (t.status === \"stale\") taskQueue.deleteTask(t);\n          if (err) {\n            if (res) res.set(\"stale\");\n          } else if (conReq !== \"OK\") {\n            if (res) res.set(\"stale\");\n          } else if (tasks2[0]?.status === \"stale\") {\n            if (res) res.set(\"stale\");\n          } else if (tasks2[0]?.status === \"fault\") {\n            if (res) res.set(\"fault\");\n          } else {\n            if (res) res.set(\"done\");\n          }\n        })\n        .then(() => invalidate(Date.now()))\n        .catch(() => {\n          if (res) res.set(\"stale\");\n        });\n    } else if (task.name === \"setParameterValues\" || task.name === \"download\") {\n      if (task.name === \"download\") taskQueue.stageDownload(task);\n      else taskQueue.stageSpv(task);\n      if (res) res.set(\"staging\");\n    } else {\n      taskQueue.queueTask(task);\n      if (res) res.set(\"queued\");\n    }\n    return null;\n  });\n}\n\nfunction doNotify(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get() as {\n      type: string;\n      message: string;\n      actions?: Record<string, () => void>;\n    } | null;\n    if (!arg?.type || !arg?.message) return null;\n\n    notifications.push(arg.type, arg.message, arg.actions);\n    return null;\n  });\n}\n\nfunction doDelete(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get() as {\n      resource: string;\n      id: string;\n    } | null;\n    if (!arg?.resource || !arg?.id) return null;\n    const res = node.attributes[\"res\"] as StateSignal<boolean | Error>;\n\n    deleteResource(arg.resource, arg.id)\n      .then(() => {\n        invalidate(Date.now());\n        if (res) res.set(true);\n      })\n      .catch((err) => {\n        if (res) res.set(err instanceof Error ? err : new Error(String(err)));\n      });\n\n    return null;\n  });\n}\n\nfunction doYamlStringify(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get();\n    const res = node.attributes[\"res\"] as StateSignal<string>;\n    if (arg === undefined || !res) return null;\n    res.set(stringify(arg));\n    return null;\n  });\n}\n\nfunction doUpdateTags(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get() as {\n      deviceId: string;\n      tags: Record<string, boolean>;\n    } | null;\n    if (!arg?.deviceId || !arg?.tags) return null;\n    const res = node.attributes[\"res\"] as StateSignal<boolean | Error>;\n\n    updateTags(arg.deviceId, arg.tags)\n      .then(() => {\n        invalidate(Date.now());\n        if (res) res.set(true);\n      })\n      .catch((err) => {\n        if (res) res.set(err instanceof Error ? err : new Error(String(err)));\n      });\n\n    return null;\n  });\n}\n\nfunction doPing(node: SignalizedViewNode): ViewElement {\n  return new ComputedSignal(() => {\n    const arg = node.attributes[\"arg\"]?.get() as string | null;\n    const res = node.attributes[\"res\"] as StateSignal<number | Error | null>;\n    if (!arg || !res) return null;\n\n    const refresh = (): void => {\n      ping(arg)\n        .then((r) => {\n          res.set(r[\"avg\"] != null ? r[\"avg\"] : null);\n        })\n        .catch((err) => {\n          res.set(err instanceof Error ? err : new Error(String(err)));\n        });\n    };\n\n    refresh();\n    _setInterval(refresh, 3000);\n    return null;\n  });\n}\n\nfunction initView(context: RenderContext, node: ViewElement): ViewElement {\n  if (node instanceof SignalBase) {\n    return new ComputedSignal<ViewElement>(() => {\n      const v = initView(context, node.get());\n      if (v instanceof SignalBase) return v.get();\n      return v;\n    });\n  }\n\n  if (Array.isArray(node)) return node.map((n) => initView(context, n));\n\n  if (!(node instanceof ViewNode)) return node;\n\n  const script = context.getView(node.name);\n\n  if (script) {\n    const context2 = context.popView(node.name).pushDeferred(node.children);\n    const signalizedNode = signalizeNode(node);\n    return new ComputedSignal<ViewElement>(() => {\n      const res = script(\n        signalizedNode,\n        _setTimeout as any,\n        _setInterval as any,\n        SkewedDate as unknown as DateConstructorLike,\n      );\n\n      return initView(context2, res);\n    });\n  }\n\n  if (node.name === \"do-count\") return doCount(signalizeNode(node));\n  if (node.name === \"do-fetch\") return doFetch(signalizeNode(node));\n  if (node.name === \"do-task\") return doTask(signalizeNode(node));\n  if (node.name === \"do-notify\") return doNotify(signalizeNode(node));\n  if (node.name === \"do-delete\") return doDelete(signalizeNode(node));\n  if (node.name === \"do-ping\") return doPing(signalizeNode(node));\n  if (node.name === \"do-yaml-stringify\")\n    return doYamlStringify(signalizeNode(node));\n  if (node.name === \"do-update-tags\") return doUpdateTags(signalizeNode(node));\n\n  const children = node.children.map((child) => initView(context, child));\n  return new ViewNode(node.name, node.attributes, children);\n}\n\ntype SetTimeout = typeof setTimeout;\n\ntype DateConstructorLike = typeof globalThis.Date;\n\ntype ViewFunc = (\n  node: SignalizedViewNode,\n  setTimeout: SetTimeout,\n  setInterval: SetTimeout,\n  Date: DateConstructorLike,\n) => ViewElement;\n\nclass RenderContext {\n  private viewStacks: Record<string, ViewFunc[]>;\n  private deferredStack: ChildArray[];\n\n  constructor(clone?: RenderContext) {\n    if (clone) {\n      this.viewStacks = clone.viewStacks;\n      this.deferredStack = clone.deferredStack;\n    } else {\n      this.viewStacks = {};\n      this.deferredStack = [];\n    }\n  }\n\n  getView(name: string): ViewFunc {\n    const stack = this.viewStacks[name];\n    if (!stack) return null;\n    return stack[stack.length - 1];\n  }\n\n  pushViews(_views: Record<string, ViewFunc>): RenderContext {\n    const clone = new RenderContext(this);\n    for (const [name, view] of Object.entries(_views)) {\n      clone.viewStacks[name] = [...(clone.viewStacks[name] ?? []), view];\n    }\n    return clone;\n  }\n\n  popView(name: string): RenderContext {\n    const stack = this.viewStacks[name];\n    if (!stack?.length) return this;\n    const clone = new RenderContext(this);\n    clone.viewStacks = { ...this.viewStacks, [name]: stack.slice(0, -1) };\n    return clone;\n  }\n\n  getDeferred(): (ViewNode | SignalBase | any)[] {\n    if (!this.deferredStack.length) return null;\n    return this.deferredStack[this.deferredStack.length - 1];\n  }\n\n  popDeferred(): RenderContext {\n    const clone = new RenderContext(this);\n    clone.deferredStack = this.deferredStack.slice(0, -1);\n    return clone;\n  }\n\n  pushDeferred(deferred: (ViewNode | SignalBase | any)[]): RenderContext {\n    const clone = new RenderContext(this);\n    clone.deferredStack = [...this.deferredStack, deferred];\n    return clone;\n  }\n}\n\nfunction renderNode(node: ViewElement): ReturnType<typeof m> {\n  if (node instanceof SignalBase) {\n    return renderNode(node.get());\n  }\n  if (node instanceof ViewNode) {\n    const attrs: Record<string, unknown> = {};\n    for (const [k, v] of Object.entries(node.attributes)) {\n      attrs[k] = v instanceof SignalBase ? v.get() : v;\n    }\n    if (!node.name) return m.fragment(attrs, node.children.map(renderNode));\n    return m(node.name, attrs, node.children.map(renderNode));\n  }\n\n  if (Array.isArray(node))\n    return m.fragment(\n      {},\n      node.map((n) => renderNode(n)),\n    );\n  return m.fragment({}, node);\n}\n\nexport const ViewComponent: ClosureComponent<{\n  name: string;\n  attrs: Record<string, string>;\n}> = (vnode) => {\n  const context = new RenderContext().pushViews(\n    views as Record<string, ViewFunc>,\n  );\n  const node = initView(\n    context,\n    new ViewNode(vnode.attrs.name, vnode.attrs.attrs, []),\n  );\n\n  const signal = new ComputedSignal<ReturnType<typeof renderNode>>(() => {\n    return renderNode(node);\n  });\n\n  const watcher = new Watcher(() => {\n    requestAnimationFrame(() => {\n      watcher.watch(); // Reset notification state\n      m.redraw();\n    });\n  });\n  watcher.watch(signal);\n\n  return {\n    view: () => signal.get(),\n    onremove: () => {\n      watcher[Symbol.dispose]();\n      if (node[Symbol.dispose]) node[Symbol.dispose]();\n    },\n  };\n};\n"
  },
  {
    "path": "ui/virtual-parameters-page.ts",
    "content": "import { ClosureComponent, Component, Children } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport { pageSize as PAGE_SIZE } from \"./config.ts\";\nimport filterComponent from \"./filter-component.ts\";\nimport * as store from \"./store.ts\";\nimport * as notifications from \"./notifications.ts\";\nimport memoize from \"../lib/common/memoize.ts\";\nimport putFormComponent from \"./put-form-component.ts\";\nimport indexTableComponent from \"./index-table-component.ts\";\nimport * as overlay from \"./overlay.ts\";\nimport * as smartQuery from \"./smart-query.ts\";\nimport Expression from \"../lib/common/expression.ts\";\nimport { loadCodeMirror } from \"./dynamic-loader.ts\";\n\nconst memoizedJsonParse = memoize(JSON.parse);\n\nconst attributes = [\n  { id: \"_id\", label: \"Name\" },\n  { id: \"script\", label: \"Script\", type: \"code\", mode: \"javascript\" },\n];\n\nconst unpackSmartQuery = memoize((query: Expression) => {\n  return query.evaluate((e) => {\n    if (e instanceof Expression.FunctionCall) {\n      if (e.name === \"Q\") {\n        if (\n          e.args[0] instanceof Expression.Literal &&\n          e.args[1] instanceof Expression.Literal\n        ) {\n          return smartQuery.unpack(\n            \"virtualParameters\",\n            e.args[0].value as string,\n            e.args[1].value as string,\n          );\n        }\n      }\n    }\n    return e;\n  });\n});\n\ninterface ValidationErrors {\n  [prop: string]: string;\n}\n\nfunction putActionHandler(action, _object, isNew): Promise<ValidationErrors> {\n  return new Promise((resolve, reject) => {\n    const object = Object.assign({}, _object);\n    if (action === \"save\") {\n      const id = object[\"_id\"];\n      delete object[\"_id\"];\n\n      if (!id) return void resolve({ _id: \"ID can not be empty\" });\n\n      store\n        .resourceExists(\"virtualParameters\", id)\n        .then((exists) => {\n          if (exists && isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Virtual parameter already exists\" });\n          }\n\n          if (!exists && !isNew) {\n            store.setTimestamp(Date.now());\n            return void resolve({ _id: \"Virtual parameter does not exist\" });\n          }\n\n          store\n            .putResource(\"virtualParameters\", id, object)\n            .then(() => {\n              notifications.push(\n                \"success\",\n                `Virtual parameter ${exists ? \"updated\" : \"created\"}`,\n              );\n              store.setTimestamp(Date.now());\n              resolve(null);\n            })\n            .catch((err) => {\n              if (err[\"code\"] === 400 && err[\"response\"]) {\n                reject(new Error(err[\"response\"]));\n                return;\n              }\n              reject(err);\n            });\n        })\n        .catch(reject);\n    } else if (action === \"delete\") {\n      if (!confirm(\"Deleting virtual parameter. Are you sure?\"))\n        return void resolve(null);\n      store\n        .deleteResource(\"virtualParameters\", object[\"_id\"])\n        .then(() => {\n          notifications.push(\"success\", \"Virtual parameter deleted\");\n          store.setTimestamp(Date.now());\n          resolve(null);\n        })\n        .catch((err) => {\n          store.setTimestamp(Date.now());\n          reject(err);\n        });\n    } else {\n      reject(new Error(\"Undefined action\"));\n    }\n  });\n}\n\nconst formData = {\n  resource: \"virtualParameters\",\n  attributes: attributes,\n};\n\nconst getDownloadUrl = memoize((filter) => {\n  const cols = {};\n  for (const attr of attributes) cols[attr.label] = attr.id;\n  return `api/virtualParameters.csv?${m.buildQueryString({\n    filter: filter.toString(),\n    columns: JSON.stringify(cols),\n  })}`;\n});\n\nexport function init(\n  args: Record<string, unknown>,\n): Promise<Record<string, unknown>> {\n  if (!window.authorizer.hasAccess(\"virtualParameters\", 2)) {\n    return Promise.reject(\n      new Error(\"You are not authorized to view this page\"),\n    );\n  }\n\n  let filter: Expression = null;\n  let sort: Record<string, number> = null;\n  if (args.hasOwnProperty(\"filter\"))\n    filter = Expression.parse(args[\"filter\"] as string);\n  if (args.hasOwnProperty(\"sort\")) sort = JSON.parse(args[\"sort\"] as string);\n\n  return new Promise((resolve, reject) => {\n    loadCodeMirror()\n      .then(() => {\n        resolve({ filter, sort });\n      })\n      .catch(reject);\n  });\n}\n\nexport const component: ClosureComponent = (): Component => {\n  return {\n    view: (vnode) => {\n      document.title = \"Virtual Parameters - GenieACS\";\n\n      function showMore(): void {\n        vnode.state[\"showCount\"] =\n          (vnode.state[\"showCount\"] || PAGE_SIZE) + PAGE_SIZE;\n        m.redraw();\n      }\n\n      function onFilterChanged(filter): void {\n        const ops = {};\n        if (!(filter instanceof Expression.Literal && filter.value))\n          ops[\"filter\"] = filter.toString();\n        if (vnode.attrs[\"sort\"]) ops[\"sort\"] = vnode.attrs[\"sort\"];\n        m.route.set(\"/virtualParameters\", ops);\n      }\n\n      const sort = vnode.attrs[\"sort\"]\n        ? memoizedJsonParse(vnode.attrs[\"sort\"])\n        : {};\n\n      const sortAttributes = {};\n      for (let i = 0; i < attributes.length; i++)\n        sortAttributes[i] = sort[attributes[i].id] || 0;\n\n      function onSortChange(sortAttrs): void {\n        const _sort = {};\n        for (const index of sortAttrs)\n          _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);\n        const ops = { sort: JSON.stringify(_sort) };\n        if (vnode.attrs[\"filter\"]) ops[\"filter\"] = vnode.attrs[\"filter\"];\n        m.route.set(\"/virtualParameters\", ops);\n      }\n\n      const filter = unpackSmartQuery(\n        vnode.attrs[\"filter\"] ?? new Expression.Literal(true),\n      );\n\n      const virtualParameters = store.fetch(\"virtualParameters\", filter, {\n        limit: vnode.state[\"showCount\"] || PAGE_SIZE,\n        sort: sort,\n      });\n\n      const count = store.count(\"virtualParameters\", filter);\n\n      const downloadUrl = getDownloadUrl(filter);\n\n      const attrs = {};\n      attrs[\"attributes\"] = attributes;\n      attrs[\"data\"] = virtualParameters.value;\n      attrs[\"total\"] = count.value;\n      attrs[\"showMoreCallback\"] = showMore;\n      attrs[\"sortAttributes\"] = sortAttributes;\n      attrs[\"onSortChange\"] = onSortChange;\n      attrs[\"downloadUrl\"] = downloadUrl;\n      attrs[\"recordActionsCallback\"] = (virtualParameter) => {\n        return [\n          m(\n            \"button.text-cyan-700 hover:text-cyan-900 font-medium\",\n            {\n              onclick: () => {\n                let cb: () => Children = null;\n                const comp = m(\n                  putFormComponent,\n                  Object.assign(\n                    {\n                      base: virtualParameter,\n                      actionHandler: (action, object) => {\n                        return new Promise<void>((resolve) => {\n                          putActionHandler(action, object, false)\n                            .then((errors) => {\n                              const errorList = errors\n                                ? Object.values(errors)\n                                : [];\n                              if (errorList.length) {\n                                for (const err of errorList)\n                                  notifications.push(\"error\", err);\n                              } else {\n                                overlay.close(cb);\n                              }\n                              resolve();\n                            })\n                            .catch((err) => {\n                              notifications.push(\"error\", err.message);\n                              resolve();\n                            });\n                        });\n                      },\n                    },\n                    formData,\n                  ),\n                );\n                cb = () => comp;\n                overlay.open(\n                  cb,\n                  () =>\n                    !comp.state[\"current\"][\"modified\"] ||\n                    confirm(\"You have unsaved changes. Close anyway?\"),\n                );\n              },\n            },\n            \"Show\",\n          ),\n        ];\n      };\n\n      if (window.authorizer.hasAccess(\"virtualParameters\", 3)) {\n        attrs[\"actionsCallback\"] = (selected: Set<string>): Children => {\n          return [\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Create new virtual parameter\",\n                onclick: () => {\n                  let cb: () => Children = null;\n                  const comp = m(\n                    putFormComponent,\n                    Object.assign(\n                      {\n                        actionHandler: (action, object) => {\n                          return new Promise<void>((resolve) => {\n                            putActionHandler(action, object, true)\n                              .then((errors) => {\n                                const errorList = errors\n                                  ? Object.values(errors)\n                                  : [];\n                                if (errorList.length) {\n                                  for (const err of errorList)\n                                    notifications.push(\"error\", err);\n                                } else {\n                                  overlay.close(cb);\n                                }\n                                resolve();\n                              })\n                              .catch((err) => {\n                                notifications.push(\"error\", err.message);\n                                resolve();\n                              });\n                          });\n                        },\n                      },\n                      formData,\n                    ),\n                  );\n                  cb = () => comp;\n                  overlay.open(\n                    cb,\n                    () =>\n                      !comp.state[\"current\"][\"modified\"] ||\n                      confirm(\"You have unsaved changes. Close anyway?\"),\n                  );\n                },\n              },\n              \"New\",\n            ),\n            m(\n              \"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n              {\n                title: \"Delete selected virtual parameters\",\n                disabled: !selected.size,\n                onclick: (e) => {\n                  if (\n                    !confirm(\n                      `Deleting ${selected.size} virtual parameters. Are you sure?`,\n                    )\n                  )\n                    return;\n\n                  e.redraw = false;\n                  e.target.disabled = true;\n                  Promise.all(\n                    Array.from(selected).map((id) =>\n                      store.deleteResource(\"virtualParameters\", id),\n                    ),\n                  )\n                    .then((res) => {\n                      notifications.push(\n                        \"success\",\n                        `${res.length} virtual parameters deleted`,\n                      );\n                      store.setTimestamp(Date.now());\n                    })\n                    .catch((err) => {\n                      notifications.push(\"error\", err.message);\n                      store.setTimestamp(Date.now());\n                    });\n                },\n              },\n              \"Delete\",\n            ),\n          ];\n        };\n      }\n\n      const filterAttrs = {\n        resource: \"virtualParameters\",\n        filter: vnode.attrs[\"filter\"],\n        onChange: onFilterChanged,\n      };\n\n      return [\n        m(\n          \"h1.text-xl font-medium text-stone-900 mb-5\",\n          \"Listing virtual parameters\",\n        ),\n        m(filterComponent, filterAttrs),\n        m(\n          \"loading\",\n          { queries: [virtualParameters, count] },\n          m(indexTableComponent, attrs),\n        ),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/wizard-page.ts",
    "content": "import { ClosureComponent, Component } from \"mithril\";\nimport { m } from \"./components.ts\";\nimport * as notifications from \"./notifications.ts\";\n\nexport async function init(): Promise<Record<string, unknown>> {\n  return m.request({ url: \"init\" });\n}\n\nexport const component: ClosureComponent = (vnode): Component => {\n  let options = vnode.attrs;\n  const selected = new Set<string>();\n  for (const [k, v] of Object.entries(options)) if (v) selected.add(k);\n\n  return {\n    view: () => {\n      document.title = \"Initialization wizard - GenieACS\";\n\n      const items = [\n        { key: \"users\", label: \"Users, roles and permissions\" },\n        { key: \"presets\", label: \"Presets and provisions\" },\n        { key: \"filters\", label: \"Devices predefined search filters\" },\n        { key: \"device\", label: \"Device details page\" },\n        { key: \"index\", label: \"Devices listing page\" },\n        { key: \"overview\", label: \"Overview page\" },\n      ];\n\n      return [\n        m(\n          \"h1.text-xl font-medium text-stone-900 mb-5\",\n          \"Initialization wizard\",\n        ),\n        m(\".bg-white shadow-sm rounded-lg p-6 sm:p-8 max-w-lg\", [\n          m(\n            \"p.text-sm text-stone-600 mb-6\",\n            \"This wizard will seed the database with a minimal initial configuration to serve as a starting point. Select what you want to initialize and click the button below.\",\n          ),\n          m(\n            \".flex flex-col gap-3 mb-6\",\n            items.map((item) => {\n              if (!options[item.key]) selected.delete(item.key);\n              return m(\n                \"label.flex items-center text-sm text-stone-700\",\n                { class: options[item.key] ? \"\" : \"opacity-50\" },\n                m(\n                  \"input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm\",\n                  {\n                    type: \"checkbox\",\n                    checked: selected.has(item.key),\n                    disabled: !options[item.key],\n                    onclick: (e) => {\n                      if (e.target.checked) selected.add(item.key);\n                      else selected.delete(item.key);\n                    },\n                  },\n                ),\n                m(\"span.ml-2\", item.label),\n              );\n            }),\n          ),\n          m(\n            \"button.inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed\",\n            {\n              disabled: selected.size === 0,\n              onclick: (e) => {\n                e.target.disabled = true;\n\n                const opts = {};\n                for (const s of selected) opts[s] = true;\n\n                m.request({\n                  method: \"POST\",\n                  url: \"init\",\n                  body: opts,\n                })\n                  .then(() => {\n                    setTimeout(() => {\n                      m.request({ url: \"init\" })\n                        .then((o) => {\n                          e.target.disabled = false;\n                          options = o;\n                          notifications.push(\n                            \"success\",\n                            \"Initialization complete\",\n                            {\n                              \"Open Sesame!\": () => {\n                                m.route.set(\"/login\");\n                                window.location.reload();\n                              },\n                            },\n                          );\n                        })\n                        .catch((err) => {\n                          notifications.push(\"error\", err.message);\n                        });\n                    }, 3000);\n                    if (opts[\"users\"]) {\n                      alert(\n                        \"An administrator user has been created for you. Use admin/admin to log in. Don't forget to change the default password.\",\n                      );\n                    }\n                  })\n                  .catch((err) => {\n                    notifications.push(\"error\", err.message);\n                  });\n              },\n            },\n            \"ABRACADABRA!\",\n          ),\n        ]),\n      ];\n    },\n  };\n};\n"
  },
  {
    "path": "ui/yaml-loader.ts",
    "content": "export { parse, stringify } from \"yaml\";\n"
  }
]