commit 351f89a9a4da965af08f82d04d558b450d091b85 Author: jmcqueen Date: Wed Jul 1 16:55:03 2026 -0400 Initial commit: CollabFinder Webex bot Multi-integration Webex chat/HTTP bot that unifies phone, AV, and network status for retail store support. Consolidates data from Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital signage, and OptiSigns into rich per-store status commands. Key surfaces: - /phonestatus, /avstatus — per-store phone & AV device reports with clickable Meraki deep-links and per-port detail. - /webexhost — check/assign Webex Meetings host licenses via the Service App; adaptive-card confirmation flow, HTTP-API-gated. - /offboarduser — full Webex Admin offboarding (auth revoke, device wipe, license removal); adaptive-card confirmation. - /jirapoll — on-demand trigger for the hourly Jira poller. - /bulkavstatuscsv — bulk store CSV export with concurrency limits. Automation: - Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier that categorizes unassigned tickets as phone/av/skip, extracts store numbers from free-text, and enriches Jira with the same detailed markdown the chat commands emit (converted to Jira ADF, preserves bold + Meraki links). Idempotent via a `bot-enriched` Jira label. Architecture: - Node.js 20+, ESM, Express 5, webex-node-bot-framework. - Layered integrations (integrations/*), services (services/*), commands (commands/*), utils (utils/*). - Shared markdown renderers (services/renderers/*) feed both chat handlers and the Jira poller so the two surfaces stay in sync. - Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) — no new npm dependency. - Node built-in test runner (`node --test tests/*.test.js`), 30 tests covering the converter, renderers, and poller ADF assembly. Docker + docker-compose deployment. Config via .env (see .env.example for the full option surface). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca87f76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Dependencies +node_modules/ + +# Git & IDE +.git +.gitignore +.idea/ +.vscode/ + +# Environment & Secrets (brought in via env_file or explicit mounts) +.env +.env.* +*.pem +*.key +debug_cert_*.pem + +# Logs & local data +logs/ +storage/ +*.log + +# OS / Temp +.DS_Store +Thumbs.db +*.bak +*~ +*.swp + +# Test & demo artifacts +testobjects.json +meraki-store-topology-demo.html + +# Dev / characterization / scripts (local only, not needed in runtime Docker image) +characterization-runs/ +scripts/ +characterize-*.js + +# Documentation & misc +*.md +Dockerfile* +docker-compose*.yml +.dockerignore + +# Don't copy the old config if we're moving away from it +config/config.json +config/config.bak + +# Keep the rotating tokens file out of the image (will be mounted at runtime from host) +config/webex-service-tokens.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4da8e2d --- /dev/null +++ b/.env.example @@ -0,0 +1,222 @@ +# ============================================================================= +# CollabFinder / CollabSupport Environment Variables +# Copy this file to .env and fill in your values. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Server +# ----------------------------------------------------------------------------- +SERVER_PORT=1800 + +# Logging level: info (default - clean), debug (verbose, includes per-fetch details) +LOG_LEVEL=info + +# Verbose Webex framework debug logs. Default off; auto-enabled when LOG_LEVEL=debug. +# WEBEX_FRAMEWORK_DEBUG=false + +# ----------------------------------------------------------------------------- +# HTTP API authentication +# ----------------------------------------------------------------------------- +# Shared secret required to call destructive /:command HTTP endpoints such as +# /offboarduser, /provision-dect, /provision-vc, /vcmonitor, /bulkavstatuscsv, +# /bulkavswitchcsv, /devicesbymodel. Without it, those endpoints fail-closed +# with HTTP 503 — set this to any high-entropy string (e.g. `openssl rand -hex 32`). +# Callers send the token as `Authorization: Bearer ` or `X-API-Token: `. +HTTP_API_TOKEN= + +# Optional. When set to "true" / "1" / "yes", the same token is also required +# for read-only endpoints (/avstatus, /phonestatus, /av/devices/build/…, +# /phone/devices/build/…, /api/av/*, etc.). Default = false (those endpoints +# stay open so the bundled dashboards keep working without auth headers). +HTTP_API_REQUIRE_AUTH=false + +# ----------------------------------------------------------------------------- +# Webex Bot (required) +# ----------------------------------------------------------------------------- +# Bot token from developer.webex.com. The framework connects to Webex over +# websockets using this token, so no public ingress / webhook URL is needed. +WEBEX_BOT_TOKEN=your-bot-token-here + +# Service App credentials (used by WebexServiceAppAuth for backend Webex API +# calls — people lookup, room operations, etc.). Separate from the bot token. +# +# Required scopes (set when creating the service app at developer.webex.com): +# - spark-admin:people_read (people lookup) +# - identity:tokens_read (offboarduser: list a user's authorizations) +# - identity:tokens_write (offboarduser: revoke a user's authorizations) +# The authorizing admin must also hold Full / User / Device Admin role for the +# token-management calls to succeed. +WEBEX_CLIENT_ID=your-service-app-client-id +WEBEX_CLIENT_SECRET=your-service-app-client-secret + +# Path to the rotating service app tokens file (must be writable). +# IMPORTANT: Use a *relative* path (e.g. ./config/...). The same .env works for both: +# - Local runs (resolved against your project root cwd) +# - Docker (resolved against /app inside container; see docker-compose volume mount) +# Do NOT use an absolute host path here — it will break inside the container. +WEBEX_TOKENS_PATH=./config/webex-service-tokens.json + +# Optional override for the Webex API base URL (default https://webexapis.com/v1). +# WEBEX_BASE_URL=https://webexapis.com/v1 + +# ----------------------------------------------------------------------------- +# /webexhost — Webex Meetings host license helper +# ----------------------------------------------------------------------------- +# Site to evaluate host status against. Default: aeo2go.webex.com. +# WEBEX_HOST_SITE_URL=aeo2go.webex.com + +# License ID auto-assigned by `/webexhost ` when the user is missing a +# host license on the site. Discover the right id by running `/webexhost list` +# (lists every meeting license on the site with id + remaining seats). +# Until this is set, `/webexhost ` will still report status, but the +# confirm-assign step refuses with a clear message pointing at /webexhost list. +WEBEX_HOST_LICENSE_ID= + +# ----------------------------------------------------------------------------- +# Jira (required for /jira* commands) +# Use JIRA_CLOUD_ID for service accounts / new Atlassian API gateway endpoints: +# JIRA_CLOUD_ID= +# (constructs https://api.atlassian.com/ex/jira//rest/api/3 ...) +# Otherwise fall back to classic site base: +# JIRA_BASE_URL=https://your-org.atlassian.net +# ----------------------------------------------------------------------------- +JIRA_CLOUD_ID= +JIRA_BASE_URL=https://your-org.atlassian.net +JIRA_EMAIL=your-email@company.com +JIRA_API_TOKEN=your-jira-api-token +JIRA_MAX_RESULTS=30 + +# ----------------------------------------------------------------------------- +# Jira Poller (hourly ticket enrichment) +# ----------------------------------------------------------------------------- +# Cron poller that scans unassigned tickets in the AV / Comm Services / +# Mobility queue every hour, posts a phone or AV status snapshot as a +# Jira comment on each store-scoped ticket, labels the ticket +# `bot-enriched` so it's not re-processed, and posts a summary of newly +# enriched tickets to a Webex space. +# +# Required scopes on JIRA_API_TOKEN: read + write on issues in the +# target projects (comment + edit-labels). The token owner needs "Add +# Comments" and "Edit Issues" permission — a plain read-only integration +# token WILL NOT work. +# +# JIRA_POLLER_ROOM_ID +# Webex space roomId to post the per-poll "N new tickets" summary to. +# Poller stays DISABLED (cron never registered) if unset — safe default +# for dev instances that share the same Jira credentials. +# +# JIRA_POLLER_PRIME_ON_START +# One-shot backlog-prime toggle. Set to `true` for a SINGLE deploy to +# bulk-label every ticket currently matching the poller's JQL as +# `bot-enriched` WITHOUT enriching them or posting a summary. Prevents +# day-one spam from a queue that already has dozens of open tickets. +# Flip back to `false` (or remove) before the next restart or the +# prime pass runs again. +# +# JIRA_STORE_FIELD_ID +# Optional. Numeric custom-field id for the `Store Number` field +# (e.g. `customfield_10042`). If unset, the poller discovers it at +# first use via GET /rest/api/3/field. Set explicitly to skip +# discovery (saves one API call at startup) or when the display +# name resolves ambiguously in your Jira schema. +# +# Note: on tenants where Store Number is an Atlassian Assets object +# reference (not a plain string), the field value the poller reads +# will be an opaque object like {"objectId":"81255"}. The AI +# classifier handles this by extracting the store number from the +# ticket summary / description text instead ("Store 3860 - ..."), so +# the field being unreadable is not fatal. +# +# JIRA_POLLER_MODEL +# Optional model override for the AI ticket classifier. Defaults to +# XAI_MODEL if unset. Classification is a small, deterministic +# structured task (~50-token JSON output per ticket) that doesn't +# need the reasoning depth of the summary model — a cheaper/faster +# model (e.g. `grok-3-mini`) saves noticeable money at scale without +# hurting classification accuracy on the phone/av/skip taxonomy. +# ----------------------------------------------------------------------------- +JIRA_POLLER_ROOM_ID= +JIRA_POLLER_PRIME_ON_START=false +JIRA_STORE_FIELD_ID= +JIRA_POLLER_MODEL= + +# ----------------------------------------------------------------------------- +# xAI / Grok (used for ticket and work order summarization) +# ----------------------------------------------------------------------------- +XAI_URL=https://api.x.ai/v1/chat/completions +XAI_API_KEY=your-xai-api-key +XAI_MODEL=grok-2-latest + +# ----------------------------------------------------------------------------- +# Meraki +# ----------------------------------------------------------------------------- +MERAKI_API_KEY=your-meraki-api-key +MERAKI_ORG_ID=your-org-id + +# ----------------------------------------------------------------------------- +# RED (Digital Signage) +# Comma-separated list of company IDs (one per company tenant). +# ----------------------------------------------------------------------------- +RED_BASE_URL=https://api.red.com +RED_CLIENT_ID=your-red-client-id +RED_API_KEY=your-red-api-key +RED_COMPANY_IDS=company-id-1,company-id-2,company-id-3 + +# ----------------------------------------------------------------------------- +# OptiSigns +# ----------------------------------------------------------------------------- +OPTISIGN_API_KEY=your-optisigns-api-key + +# ----------------------------------------------------------------------------- +# DigiCert (for VC provisioning) +# ----------------------------------------------------------------------------- +DIGICERT_API_KEY=your-digicert-key +DIGICERT_BASE_URL=https://one.digicert.com +DIGICERT_PROFILE_ID=your-profile-id +DIGICERT_SEAT_EMAIL=your-email@company.com + +# ----------------------------------------------------------------------------- +# MDM / Workspace ONE (two instances) +# ----------------------------------------------------------------------------- +# Standard / Store MDM +WS1_CLIENT_ID=... +WS1_CLIENT_SECRET=... +WS1_TENANT_CODE=... + +# CORP MDM (used for offboarding / enterprise wipes) +CORP_WS1_API_BASE=https://... +CORP_WS1_CLIENT_ID=... +CORP_WS1_CLIENT_SECRET=... +CORP_WS1_TENANT_CODE=... + +# ----------------------------------------------------------------------------- +# Atlas +# ----------------------------------------------------------------------------- +ATLAS_AUTH_KEY=your-atlas-key + +# ----------------------------------------------------------------------------- +# ServiceChannel +# OAuth password grant against ServiceChannel's identity endpoint. +# ----------------------------------------------------------------------------- +SC_BASE_URL=https://api.servicechannel.com/v3 +SC_OAUTH_URL=https://login.servicechannel.com/oauth/token +SC_CLIENT_ID=your-sc-client-id +SC_CLIENT_SECRET=your-sc-client-secret +SC_USERNAME=your-sc-username@company.com +SC_PASSWORD=your-sc-password + +# ----------------------------------------------------------------------------- +# VC Provisioning "backdoor" account +# Used by vcProvisionService for local-device authentication during certificate +# enrollment. DIGICERT_SEAT_EMAIL above is unrelated. +# ----------------------------------------------------------------------------- +BACKDOOR_USERNAME=monitor +BACKDOOR_PASSWORD=... + +# ----------------------------------------------------------------------------- +# Notes +# ----------------------------------------------------------------------------- +# - config/config.json has been fully removed. All configuration is via env vars. +# - The rotating Webex service token lives in config/webex-service-tokens.json +# and is mounted separately when running in Docker. +# - Add any new integration keys above following the same pattern. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..008cf5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Dependencies +node_modules/ + +# Environment & Secrets +.env +.env.local +.env.*.local +*.pem +*.cer +*.key +debug_cert_*.pem + +# The rotating service token file must be provided locally for Docker mounts (and runtime writes). +# Never commit real tokens. +config/webex-service-tokens.json + +# Logs & Runtime data +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Storage / Certificates / Debug artifacts (provide aeoroots.cer etc. locally for your clone; volume mounted in Docker) +storage/ + +# Dev / test artifacts (local only) +characterization-runs/ +scripts/ +characterize-*.js + +# Backup & temp files +*.bak +*~ +*.swp +.DS_Store +Thumbs.db + +# IDE / Editor +.idea/ +.vscode/ +*.sublime-project +*.sublime-workspace +.history/ + +# Build / Output +dist/ +build/ +coverage/ +.nyc_output/ + +# Docker / Misc +docker-compose.override.yml + +# OS generated +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# Misc project artifacts +testobjects.json +meraki-store-topology-demo.html +config/config.json +config/config.bak diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2032a71 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Dockerfile +FROM node:20-alpine + +# Install runtime tools needed by features (openssl for /provision-vc CSR generation in vcProvisionService). +# wget is provided by busybox for the HEALTHCHECK. +RUN apk add --no-cache openssl + +# Create non-root user for security +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +# Copy package files first for better layer caching +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# Copy the rest of the application +COPY . . + +# Create directories for runtime data (logs, storage, config for the mounted tokens file) +RUN mkdir -p logs storage config && chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 1800 + +# Health check (uses wget which is available in alpine) +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:1800/health || exit 1 + +# Start the app +CMD ["npm", "start"] \ No newline at end of file diff --git a/commands/avStatus.js b/commands/avStatus.js new file mode 100644 index 0000000..a9923d7 --- /dev/null +++ b/commands/avStatus.js @@ -0,0 +1,50 @@ +// src/commands/avStatus.js +// +// Chat + HTTP entry point for /avstatus. The heavy rendering lives in +// services/renderers/avStatusRenderer.js so the Jira poller can emit +// the same markdown (see services/jiraPollerService.js). This handler +// stays thin: parse args, call the collector, hand data to the renderer, +// respond. + +import { collectDeviceStatus } from '../services/deviceService.js'; +import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js'; +import { logger } from '../utils/logger.js'; + +export async function handleAvStatus(bot, trigger) { + logger('device:status', 'Handler entered', 'debug'); + + const query = trigger.query || {}; + const args = trigger.args || []; + + let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; + + const isDetailed = + (args[1]?.toLowerCase() === 'detailed') || + (query.mode === 'detailed') || + (query.detailed === 'true' || query.detailed === true); + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + const errorMsg = 'Please provide a 2–4 digit store number.\n' + + 'Example: `/avstatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki deep links) or `https://.../avstatus?storeNum=782`'; + await bot.say('markdown', errorMsg); + return; + } + + logger('device:status', `Collecting AV device status for store ${storeNum} in detailed mode`, 'debug'); + + try { + const data = await collectDeviceStatus(storeNum); + const reply = renderAvStatusMarkdown(data, { + storeNum, + detailed: isDetailed, + footer: true, + }); + await bot.say('markdown', reply); + + const mdmDevices = data.mdm?.data || []; + logger('device:status', `Rendered detailed view for ${mdmDevices.length} MDM devices`); + } catch (err) { + logger('device:status', `Error: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error collecting device status: ${err.message}`); + } +} diff --git a/commands/bulkAvStatusCSV.js b/commands/bulkAvStatusCSV.js new file mode 100644 index 0000000..04b5097 --- /dev/null +++ b/commands/bulkAvStatusCSV.js @@ -0,0 +1,301 @@ +// src/commands/bulkAvStatusCSV.js +// +// /bulkavstatuscsv — one-shot bulk AV device status report, delivered as +// a CSV attachment to the Webex room the command was invoked from. +// +// Data flow per invocation: +// 1. Pull every device from MDM (no filters). +// 2. Bucket devices by store number derived from DeviceFriendlyName. +// 3. For each store bucket, in parallel (concurrency STORE_CONCURRENCY): +// - Fetch Meraki clients for the store's network. +// - Fetch Meraki port config for MS switches in that network. +// - For each MDM device, try to match a Meraki client and (if wired) +// the specific port it lives on. +// 4. Concatenate all rows, ship as one CSV attachment. +// +// Design notes: +// - `bot.say` is used for progress messages (framework-scoped to the +// invoking room). Final CSV is posted via `botClient.sendWithAttachment` +// because the framework's own attachment helpers require reading from +// disk; sending a Buffer is easier through the singleton. +// - The command is registered `http: false` — delivery requires a +// Webex roomId for the attachment post, and we don't want the HTTP +// path to run the full MDM+Meraki pipeline only to discover it can't +// deliver the result. +// - Store enrichment is resilient: a per-store try/catch means one flaky +// store (Meraki 429, network hiccup, missing network) can't kill the +// whole report — that store's devices get a "Fetch failed: " row +// instead. A single unhandled exception in the outer try still aborts, +// but only truly unexpected failures land there. +import { logger } from '../utils/logger.js'; +import { getMDMDevicesByPlatform } from '../integrations/mdm/client.js'; +import { getClientsForStore, getPortsForStore } from '../integrations/meraki/clients.js'; +import { findBestMerakiClientMatch } from '../services/enrichment/merakiMatcher.js'; +import { normalizePlayerName } from '../utils/normalize.js'; +import { simpleTimeAgo } from '../utils/time.js'; +import botClient from '../integrations/webex/BotClient.js'; + +// Post a progress line every N completed stores. Small enough that a slow +// enrichment run still shows movement; large enough not to spam the room. +const PROGRESS_INTERVAL = 10; + +// Meraki v1 API rate limit is 10 req/sec/org; each store makes ~2 calls +// (clients + ports), and getPortsForStore internally fans out per MS +// switch. 5 stores in flight is well within budget and cuts wall-clock +// vs. the old fully-sequential loop by roughly Nx. +const STORE_CONCURRENCY = 5; + +// Upper bound on devices fetched from MDM in a single run. This is NOT a +// functional limit — it's a runaway guard so a misconfigured MDM query or +// a Workspace ONE regression can't pull an unbounded result set into +// memory. Bump this if the AV fleet legitimately grows past it; we'll +// also emit a warning log if a single run actually hits the cap so it's +// visible instead of silently truncating. +const MAX_MDM_DEVICES = 20000; + +// Kept as a single source of truth so the header line and each data row +// can't drift out of sync. +const CSV_COLUMNS = [ + 'Store Number', 'Device Name (Username)', 'Location Group', 'Model', + 'Serial Number', 'MDM Last Seen', 'Meraki Connection', 'IP', 'MAC', + 'Meraki Last Seen', 'VLAN', 'Port', 'Port Name', 'Switch Name', 'Port Type', + 'Port Status', 'Access Policy', 'Sticky MACs', 'POE', 'Errors', +]; + +// RFC 4180-ish CSV cell serializer: +// - null / undefined / '' → visible placeholder so Excel doesn't leave +// an empty column +// - wraps every value in quotes +// - doubles any embedded quotes +// - flattens embedded CR/LF to a single space so a wrapped value can't +// accidentally split the row (e.g. multi-line port error strings) +function csvCell(v) { + if (v === null || v === undefined || v === '') return '"—"'; + const s = String(v).replace(/\r?\n/g, ' ').replace(/"/g, '""'); + return `"${s}"`; +} + +// Extract a store number from an MDM DeviceFriendlyName. Prefers explicit +// 6- or 5-digit runs (typical AEO store IDs), falls back to any 2-4 digit +// run zero-padded to 5. Returns null if no digits are present — the +// caller should skip / count that device rather than crash. +export function extractStoreNumber(rawName) { + if (!rawName) return null; + return ( + rawName.match(/(\d{6})/)?.[1] || + rawName.match(/(\d{5})/)?.[1] || + rawName.match(/\d{2,4}/)?.[0]?.padStart(5, '0') || + null + ); +} + +// Concurrency-limited async map with a rolling window (not fixed-size +// batches). N runners each pull the next index off a shared cursor, so a +// slow store never stalls the queue behind it. +async function mapWithConcurrency(items, limit, worker) { + const results = new Array(items.length); + let cursor = 0; + const runners = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + while (true) { + const i = cursor++; + if (i >= items.length) return; + results[i] = await worker(items[i], i); + } + }, + ); + await Promise.all(runners); + return results; +} + +// Build the CSV rows for one store. Never throws — network failures are +// absorbed into a per-device "Fetch failed" error cell so a single flaky +// store can't discard the entire report. +async function buildStoreRows(storeNum, storeDevices) { + let clients = []; + let ports = []; + let fetchError = null; + + try { + ({ clients = [] } = await getClientsForStore(storeNum)); + ports = await getPortsForStore(storeNum); + } catch (err) { + fetchError = err.message || String(err); + logger('bulk-av-csv', `Store ${storeNum} enrichment failed: ${fetchError}`, 'warn'); + } + + const rows = []; + for (const tv of storeDevices) { + const username = tv.UserName || tv.userName || tv.User || 'Unknown'; + const locationGroup = tv.LocationGroupName || tv.locationGroup || tv.LocationGroup || '—'; + const rawNameForMatching = tv.DeviceFriendlyName || tv.friendlyName || username; + const mdmLastSeen = tv.LastSeen ? simpleTimeAgo(tv.LastSeen) : '—'; + + let connection = 'Unknown', ip = '—', mac = '—', merakiLastSeen = '—', + vlan = '—', portNum = '—', portName = '—', switchName = '—', + portType = '—', portStatus = '—', accessPolicy = '—', + stickyMacs = '0', poe = '—', + errors = fetchError ? `Fetch failed: ${fetchError}` : '—'; + + if (!fetchError) { + // Advanced matcher first (better prefix/CA/MAC handling); simple + // exact-normalize match as a fallback for compat with the older + // matching semantics. + const normMdm = normalizePlayerName(rawNameForMatching).toLowerCase().trim(); + const matchDevice = { identifier: rawNameForMatching }; + const matchingClient = findBestMerakiClientMatch(matchDevice, clients) || clients.find((c) => + normalizePlayerName(c.description || '').toLowerCase().trim() === normMdm, + ); + + if (matchingClient) { + connection = matchingClient.recentDeviceConnection || 'Unknown'; + ip = matchingClient.ip || '—'; + mac = matchingClient.mac || '—'; + merakiLastSeen = matchingClient.lastSeen ? simpleTimeAgo(matchingClient.lastSeen) : '—'; + vlan = matchingClient.vlan || '—'; + + if (connection.toLowerCase() === 'wired' && matchingClient.recentDeviceSerial && matchingClient.switchport) { + const portInfo = ports.find((p) => + (p.deviceSerial || p.serial) === matchingClient.recentDeviceSerial && + String(p.portId || p.number || p.portNumber || '') === String(matchingClient.switchport), + ); + + if (portInfo) { + portNum = portInfo.portId || portInfo.number || matchingClient.switchport; + portName = portInfo.name || '—'; + switchName = portInfo.deviceName || portInfo.switchName || matchingClient.recentDeviceSerial || '—'; + portType = portInfo.type || portInfo.portType || '—'; + portStatus = portInfo.status || '—'; + accessPolicy = portInfo.accessPolicy || portInfo.accessPolicyType || '—'; + stickyMacs = Array.isArray(portInfo.stickyMacAllowList) + ? portInfo.stickyMacAllowList.length.toString() + : '0'; + poe = portInfo.poeEnabled === true ? 'On' : (portInfo.poeEnabled === false ? 'Off' : '—'); + errors = Array.isArray(portInfo.errors) && portInfo.errors.length > 0 + ? portInfo.errors.join('; ') + : 'None'; + } + } + } + } + + rows.push([ + storeNum, username, locationGroup, tv.Model, tv.SerialNumber, + mdmLastSeen, connection, ip, mac, merakiLastSeen, vlan, + portNum, portName, switchName, portType, portStatus, + accessPolicy, stickyMacs, poe, errors, + ].map(csvCell).join(',')); + } + return rows; +} + +export async function handleBulkAvStatusCSV(bot, trigger) { + const roomId = trigger.roomId || trigger.message?.roomId; + if (!roomId) { + // Belt-and-suspenders: the registry marks this http:false so the HTTP + // dispatcher shouldn't even reach us. If it does (e.g. a future ad-hoc + // call path), surface a clear message instead of silently producing + // nothing. + await bot.say( + 'markdown', + '❌ `/bulkavstatuscsv` delivers a CSV attachment and needs a Webex ' + + 'room to post to. This command is chat-only.', + ); + return; + } + + const startedAt = Date.now(); + await bot.say('markdown', '🔄 Generating full AV Devices report...\nFetching **all devices** from MDM (no filters)...'); + + try { + const allDevices = await getMDMDevicesByPlatform(null, MAX_MDM_DEVICES); + logger('bulk-av-csv', `MDM returned ${allDevices.length} total devices`); + + // Warn (loudly) if we hit the runaway cap — the report is complete + // *up to* MAX_MDM_DEVICES but silently truncated everything beyond. + // If this fires, raise MAX_MDM_DEVICES and re-run. + if (allDevices.length >= MAX_MDM_DEVICES) { + logger( + 'bulk-av-csv', + `⚠️ Hit MAX_MDM_DEVICES cap (${MAX_MDM_DEVICES}) — report may be truncated. ` + + `Raise the cap in commands/bulkAvStatusCSV.js and re-run.`, + 'warn', + ); + await bot.say( + 'markdown', + `⚠️ **Note:** hit the ${MAX_MDM_DEVICES.toLocaleString()}-device safety cap. ` + + `The report includes the first ${MAX_MDM_DEVICES.toLocaleString()} devices only. ` + + `Ask the bot maintainer to raise \`MAX_MDM_DEVICES\` in \`commands/bulkAvStatusCSV.js\`.`, + ); + } + + // Group by store; count devices we couldn't derive a store number for + // so the operator sees they were skipped instead of assuming zero + // silently. + const devicesByStore = new Map(); + let unbucketed = 0; + for (const tv of allDevices) { + const rawName = tv.DeviceFriendlyName || tv.friendlyName || ''; + const storeNum = extractStoreNumber(rawName); + if (!storeNum) { + unbucketed++; + continue; + } + if (!devicesByStore.has(storeNum)) devicesByStore.set(storeNum, []); + devicesByStore.get(storeNum).push(tv); + } + + const storeEntries = Array.from(devicesByStore.entries()); + const totalStores = storeEntries.length; + + await bot.say( + 'markdown', + `📦 Grouped ${allDevices.length} devices into **${totalStores}** stores` + + (unbucketed ? ` (${unbucketed} skipped — no store digits in device name)` : '') + + `. Running Meraki enrichment with concurrency ${STORE_CONCURRENCY}...`, + ); + + let processedStores = 0; + const nested = await mapWithConcurrency(storeEntries, STORE_CONCURRENCY, async ([storeNum, storeDevices]) => { + const rows = await buildStoreRows(storeNum, storeDevices); + processedStores++; + if (processedStores % PROGRESS_INTERVAL === 0 || processedStores === totalStores) { + // Fire-and-forget progress post — don't await inside the runner + // or a slow bot.say would starve the concurrency window. + bot.say('markdown', `✅ Progress: ${processedStores}/${totalStores} stores processed...`) + .catch((err) => logger('bulk-av-csv', `Progress post failed: ${err.message}`, 'debug')); + } + return rows; + }); + + // Build the final CSV in one shot with array-join (linear) instead of + // repeated string concat (quadratic). + const headerRow = CSV_COLUMNS.map(csvCell).join(','); + const csv = [headerRow, ...nested.flat()].join('\n') + '\n'; + + const buffer = Buffer.from(csv, 'utf8'); + const filename = `AV_Devices_${new Date().toISOString().slice(0, 10)}.csv`; + const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); + logger( + 'bulk-av-csv', + `Completed in ${elapsedSec}s — ${allDevices.length} devices across ${totalStores} stores` + + (unbucketed ? ` (${unbucketed} unbucketed)` : ''), + ); + + await botClient.sendWithAttachment( + roomId, + buffer, + filename, + 'text/csv', + `✅ **AV Devices Report Complete** — ${elapsedSec}s\n` + + `${allDevices.length} total devices from ${totalStores} stores` + + (unbucketed ? ` (${unbucketed} devices skipped — no store digits in name)` : '') + + `\nDevice Name = Username • Location Group added`, + ); + + } catch (err) { + logger('bulk-av-csv', `Error: ${err.message}\n${err.stack}`, 'error'); + await bot.say('markdown', `❌ Error: ${err.message}`); + } +} diff --git a/commands/bulkAvSwitchCSV.js b/commands/bulkAvSwitchCSV.js new file mode 100644 index 0000000..b9c6a2a --- /dev/null +++ b/commands/bulkAvSwitchCSV.js @@ -0,0 +1,116 @@ +// src/commands/bulkAvSwitchCSV.js +import { logger } from '../utils/logger.js'; +import { getMerakiNetworks } from '../integrations/meraki/networks.js'; +import { fetchAllPages } from '../integrations/meraki/client.js'; +import botClient from '../integrations/webex/BotClient.js'; + +const PROGRESS_INTERVAL = 50; // Progress update every 50 rear switches + +export async function handleBulkAvSwitchCSV(bot, trigger) { + const roomId = trigger.roomId || trigger.message?.roomId; + if (!roomId) return; + + await bot.say('markdown', '🔄 Generating full Rear Switch Port Report (VLAN 340/145)...\nThis may take a few minutes...'); + + try { + const networks = await getMerakiNetworks(); + logger('bulk-av-switch', `Loaded ${networks.length} networks`); + + let csv = 'Switch Name,Port Number,Description,Port State,POE,Port Type,Data VLAN,Voice VLAN,Access Policy,Sticky Assigned,Sticky Allowed,Port Status,Speed,Duplex,Power Used,Errors,Warnings\n'; + + let totalPortsFound = 0; + let rearSwitchCount = 0; + let processedSwitches = 0; + + for (const net of networks) { + const devices = await fetchAllPages(`/networks/${net.id}/devices`); + + const rearSwitches = devices.filter(device => + device.model && device.model.startsWith('MS') && + device.name && device.name.toUpperCase().includes('R') + ); + + for (const sw of rearSwitches) { + rearSwitchCount++; + processedSwitches++; + + // Be nice to the API between switches + await new Promise(resolve => setTimeout(resolve, 150)); // 150ms delay + + // Port configuration + const configPorts = await fetchAllPages(`/devices/${sw.serial}/switch/ports`); + + // Port statuses (operational data) + let statusPorts = []; + try { + statusPorts = await fetchAllPages(`/devices/${sw.serial}/switch/ports/statuses`); + } catch (e) { + logger('bulk-av-switch', `Statuses failed for ${sw.serial}`); + } + + const statusMap = new Map(); + statusPorts.forEach(s => { + if (s.portId) statusMap.set(String(s.portId), s); + }); + + for (const port of configPorts) { + const vlan = port.vlan || port.dataVlan || null; + if (vlan && (vlan === 340 || vlan === 145)) { + totalPortsFound++; + + const stickyAssigned = Array.isArray(port.stickyMacAllowList) + ? port.stickyMacAllowList.length + : 0; + + const stickyAllowed = port.stickyMacAllowListLimit || 0; + const portState = port.enabled === true ? 'Enabled' : (port.enabled === false ? 'Disabled' : '—'); + + const statusInfo = statusMap.get(String(port.portId || port.number)) || {}; + + const portStatus = statusInfo.status || '—'; + const speed = statusInfo.speed || '—'; + const duplex = statusInfo.duplex || '—'; + + let powerUsed = '—'; + if (statusInfo.powerUsageInWh !== undefined) { + powerUsed = statusInfo.powerUsageInWh > 0 + ? `${statusInfo.powerUsageInWh} Wh` + : '0 Wh'; + } + + const errors = Array.isArray(statusInfo.errors) && statusInfo.errors.length > 0 + ? statusInfo.errors.join('; ') + : 'None'; + + const warnings = Array.isArray(statusInfo.warnings) && statusInfo.warnings.length > 0 + ? statusInfo.warnings.join('; ') + : 'None'; + + csv += `"${sw.name || '—'}","${port.portId || port.number || '—'}","${port.name || '—'}","${portState}","${port.poeEnabled === true ? 'On' : (port.poeEnabled === false ? 'Off' : '—')}","${port.type || '—'}","${vlan}","${port.voiceVlan || '—'}","${port.accessPolicyType || port.accessPolicy || '—'}","${stickyAssigned}","${stickyAllowed}","${portStatus}","${speed}","${duplex}","${powerUsed}","${errors}","${warnings}"\n`; + } + } + + if (processedSwitches % PROGRESS_INTERVAL === 0) { + await bot.say('markdown', `✅ Progress: ${processedSwitches} rear switches processed... (${rearSwitchCount} total rear switches so far)`); + } + } + } + + const buffer = Buffer.from(csv, 'utf8'); + const filename = `AV_Rear_Switch_Ports_${new Date().toISOString().slice(0,10)}.csv`; + + await botClient.sendWithAttachment( + roomId, + buffer, + filename, + 'text/csv', + `✅ **Rear Switch Port Report Complete**\n` + + `• Rear switches processed: **${rearSwitchCount}**\n` + + `• Ports with VLAN 340 or 145: **${totalPortsFound}**` + ); + + } catch (err) { + logger('bulk-av-switch', `Error: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error generating report: ${err.message}`); + } +} \ No newline at end of file diff --git a/commands/help.js b/commands/help.js new file mode 100644 index 0000000..a8e7caa --- /dev/null +++ b/commands/help.js @@ -0,0 +1,272 @@ +// src/commands/help.js +// +// Help renderer. +// +// /help → short, scannable list of every command (one line each) +// with grouped headings. +// /help → detailed usage + examples for one command. +// +// The previous version was a single 5KB blob of markdown with hard-to-read +// inline URLs. This split keeps the top-level message short for both 1:1 and +// group spaces, and pushes verbose detail behind `/help `. + +const SHORT_HELP = { + // Work orders + wohistory: 'Recent work order history for a store', + wosummary: 'AI summary of a specific work order', + woattachments: 'Download all attachments for a work order', + + // AV / phones + avstatus: 'AV / device status for a store (alias: /wostatus)', + phonestatus: 'DECT + IP phone status for a store', + + // Jira + jirahistory: 'Recent Jira tickets for a store (optionally filtered by component)', + jiraticket: 'Detailed AI summary of one Jira ticket', + jirapoll: 'Run the hourly Jira poller on demand (enrich unassigned AV/phone tickets)', + + // Provisioning / mutating + 'provision-dect': 'Interactive DECT provisioning card (add/remove bases & handsets)', + 'provision-vc': 'Fully provision a video conferencing device', + vcmonitor: 'On-demand VC packet capture (start/stop/status)', + offboarduser: 'Offboard user: revoke Webex OAuth tokens + wipe MDM CORP devices', + webexhost: 'Check / assign Webex Meetings host license on aeo2go.webex.com', + + // Bulk / utility + bulkavstatuscsv: 'Generate full AV devices report (CSV)', + bulkavswitchcsv: 'Generate rear-switch port report (CSV)', + devicesbymodel: 'Meraki devices grouped by model (CSV)', +}; + +const LONG_HELP = { + avstatus: { + title: '/avstatus', + usage: ['/avstatus ', '/avstatus detailed'], + examples: ['/avstatus 782', '/avstatus 782 detailed'], + notes: [ + 'Aliases: `/wostatus`.', + 'HTTP equivalent: `?storeNum=&detailed=true`.', + 'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.', + ], + }, + phonestatus: { + title: '/phonestatus', + usage: ['/phonestatus ', '/phonestatus detailed'], + examples: ['/phonestatus 782', '/phonestatus 782 detailed'], + notes: [ + 'Shows DECT basestations + IP phones with Meraki links.', + 'Detailed mode adds firmware, serial, SIP details and errors.', + 'Web dashboard: `/phone-store-dashboard.html`.', + ], + }, + 'provision-dect': { + title: '/provision-dect', + usage: ['/provision-dect '], + examples: ['/provision-dect 782'], + notes: [ + 'Aliases: `/provisiondect`.', + 'Posts an interactive card to add/remove bases and handsets.', + 'Looks up "Store XXXX" network using 5-digit person email; access codes are auto-generated.', + ], + }, + 'provision-vc': { + title: '/provision-vc', + usage: ['/provision-vc ', '/provision-vc '], + examples: ['/provision-vc FOC2419NTN2', '/provision-vc FOC2419NTN2 todd'], + notes: [ + 'Aliases: `/vcprovision` (legacy).', + '`` may be partial name (e.g. "todd", "american") or an org ID.', + ], + }, + vcmonitor: { + title: '/vcmonitor', + usage: ['/vcmonitor [start|stop|status] [Full|Limited|FullRotate]'], + examples: [ + '/vcmonitor FOC2419NTN2', + '/vcmonitor FOC2419NTN2 Limited', + '/vcmonitor FOC2419NTN2 stop', + '/vcmonitor FOC2419NTN2 status', + ], + notes: [ + 'Default action: `start Full` (~3 min capture including RTP).', + 'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.', + ], + }, + wohistory: { + title: '/wohistory', + usage: ['/wohistory '], + examples: ['/wohistory 782'], + notes: ['In a store-linked space, the store can be omitted.'], + }, + wosummary: { + title: '/wosummary', + usage: ['/wosummary '], + examples: ['/wosummary 12345678'], + }, + woattachments: { + title: '/woattachments', + usage: ['/woattachments '], + examples: ['/woattachments 12345678'], + }, + jirahistory: { + title: '/jirahistory', + usage: [ + '/jirahistory ', + '/jirahistory ', + '/jirahistory all', + ], + examples: [ + '/jirahistory 782', + '/jirahistory 782 phone', + '/jirahistory 782 av,voice,mobility', + ], + notes: [ + 'Component shortcuts: `av` → Audio Visual, `phone`/`voice` → Communication Services, `mobility` → Mobility.', + ], + }, + jiraticket: { + title: '/jiraticket', + usage: ['/jiraticket '], + examples: ['/jiraticket SUPPORT-817694'], + }, + jirapoll: { + title: '/jirapoll', + usage: ['/jirapoll', '/jirapoll prime'], + examples: ['/jirapoll', '/jirapoll prime'], + notes: [ + 'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`.', + 'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.', + '`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.', + 'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.', + ], + }, + webexhost: { + title: '/webexhost', + usage: [ + '/webexhost ', + '/webexhost list', + '/webexhost debug ', + ], + examples: [ + '/webexhost jdoe@company.com', + '/webexhost list', + '/webexhost debug jdoe@company.com', + ], + notes: [ + '`/webexhost ` checks whether the user holds any Webex Meetings host license on the configured site (default `aeo2go.webex.com`, override via `WEBEX_HOST_SITE_URL`).', + 'If they already have one, the command reports which license. If not — and `WEBEX_HOST_LICENSE_ID` is set in `.env` — it posts a confirmation card to assign that license.', + '`/webexhost list` is a discovery helper: lists every meeting license on the site with id + remaining seats, marking the one currently configured for auto-assign.', + '`/webexhost debug ` dumps the raw Webex payload for the user (search-endpoint vs `GET /people/{id}` license counts, intersection with site licenses) — use this when Control Hub disagrees with what the bot says.', + 'Requires service-app scopes: `spark-admin:licenses_read` + `spark-admin:people_write`. Scope/role failures surface inline.', + 'There is no Webex API for the "host vs attendee" account flag itself — host status is determined entirely by holding a meeting license on the site (confirmed via Cisco docs + wxc_sdk source).', + 'Audit log: requests + outcomes are emitted under the `webexhost:audit` scope with the requester identity (chat email or `via HTTP API`).', + ], + }, + offboarduser: { + title: '/offboarduser', + usage: ['/offboarduser '], + examples: ['/offboarduser jdoe@company.com'], + notes: [ + 'Posts a confirmation card showing the Webex user + MDM CORP devices that will be acted on.', + 'Confirming revokes every Webex OAuth authorization for the user (signs them out of all Webex clients) and enterprise-wipes each listed MDM CORP device — both run in parallel.', + 'Requires the Webex service app to hold `identity:tokens_read` + `identity:tokens_write` scopes and an admin with Full / User / Device Admin role. Scope/role failures surface inline in the success message.', + '`Hide from search` is **not** part of this command — Cisco only exposes that toggle in Control Hub, not via any Webex API. Set it manually if your offboarding policy requires it.', + 'Every request and outcome is logged under the `offboard:audit` scope with the requesting user (chat email or `via HTTP API`).', + ], + }, + bulkavstatuscsv: { + title: '/bulkavstatuscsv', + usage: ['/bulkavstatuscsv'], + notes: ['Long-running. Posts CSV attachment back to the calling room.'], + }, + bulkavswitchcsv: { + title: '/bulkavswitchcsv', + usage: ['/bulkavswitchcsv'], + notes: [ + 'Long-running. Walks every rear switch in Meraki for VLAN 340/145 ports.', + 'Posts CSV attachment back to the calling room.', + ], + }, + devicesbymodel: { + title: '/devicesbymodel', + usage: ['/devicesbymodel'], + notes: ['Posts a Meraki by-model count + CSV to the calling room.'], + }, +}; + +const GROUPS = [ + { title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] }, + { title: 'AV & phones', keys: ['avstatus', 'phonestatus'] }, + { title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] }, + { title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] }, + { title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] }, +]; + +function renderTopLevelHelp(isGroup) { + const lines = ['### CollabSupport Bot Help', '']; + for (const g of GROUPS) { + lines.push(`**${g.title}**`); + for (const key of g.keys) { + const short = SHORT_HELP[key]; + if (!short) continue; + lines.push(`- \`/${key}\` — ${short}`); + } + lines.push(''); + } + lines.push('Type `/help ` for usage and examples (e.g. `/help avstatus`).'); + if (isGroup) { + lines.push('In a space linked to a store, most commands accept no arguments and pick up the store automatically.'); + } + lines.push('Web dashboards: `/av-store-dashboard.html`, `/phone-store-dashboard.html`.'); + return lines.join('\n'); +} + +function renderCommandHelp(name) { + const detail = LONG_HELP[name]; + if (!detail) return null; + const lines = [`### ${detail.title}`]; + if (SHORT_HELP[name]) { + lines.push('', SHORT_HELP[name]); + } + if (detail.usage?.length) { + lines.push('', '**Usage:**'); + for (const u of detail.usage) lines.push(`- \`${u}\``); + } + if (detail.examples?.length) { + lines.push('', '**Examples:**'); + for (const ex of detail.examples) lines.push(`- \`${ex}\``); + } + if (detail.notes?.length) { + lines.push('', '**Notes:**'); + for (const n of detail.notes) lines.push(`- ${n}`); + } + return lines.join('\n'); +} + +export async function handleHelp(bot, trigger) { + const isGroup = trigger.message?.roomType === 'group'; + + // First arg (after `/help`) selects a specific command's detail page. + // Strip a leading slash so both `/help avstatus` and `/help /avstatus` work. + const args = trigger.args || []; + const requested = (args[0] || trigger.query?.command || '') + .toString() + .trim() + .toLowerCase() + .replace(/^\//, ''); + + if (requested) { + const detail = renderCommandHelp(requested); + if (detail) { + await bot.say('markdown', detail); + return; + } + await bot.say( + 'markdown', + `Unknown command: \`${requested}\`.\n\n${renderTopLevelHelp(isGroup)}`, + ); + return; + } + + await bot.say('markdown', renderTopLevelHelp(isGroup)); +} diff --git a/commands/jiraHistory.js b/commands/jiraHistory.js new file mode 100644 index 0000000..227b1a5 --- /dev/null +++ b/commands/jiraHistory.js @@ -0,0 +1,146 @@ +// src/commands/jiraHistory.js +import { + getJiraTicketsForStore, + getJiraTicketsForComponentWithStore, + getStatusEmoji, + calculateDaysOpen +} from '../services/jiraService.js'; +import { summarizeJiraTicket } from '../services/jiraSummarizer.js'; +import { analyzeCommonIssues } from '../services/jiraSummarizer.js'; +import jira from '../integrations/jira/JiraClient.js'; +import { logger } from '../utils/logger.js'; + +const MAX_TICKETS = 20; + +export async function handleJiraHistory(bot, trigger) { + logger('jira:history', 'Handler entered', 'debug'); + + // Support both Webex (args) and HTTP (query) + const query = trigger.query || {}; + const args = trigger.args || []; + + const arg1 = args[0]?.trim() || query.storeNum || query.store || query.s; + const arg2 = args[1]?.trim() || query.component || query.components; + + logger('jira:history', `Request for store/component: ${arg1} | ${arg2 || 'all'}`); + + if (!arg1) { + const usage = '**Jira History Usage:**\n' + + '`/jiraHistory 782` → All tickets (max 20)\n' + + '`/jiraHistory 782 phone` → Communication Services only\n' + + '`/jiraHistory 782 av,voice,mobility` → Multiple components'; + + await bot.say('markdown', usage); + return; + } + + if (!/^\d{2,4}$/.test(arg1)) { + await bot.say('markdown', 'Please provide a valid 2-4 digit store number.'); + return; + } + + const storeNum = arg1.padStart(5, '0'); // normalize to 5 digits if needed + + try { + let reply = `**Jira Tickets - Store ${storeNum}**\n\n`; + + let ticketKeys = []; + + if (!arg2 || arg2.toLowerCase() === 'all') { + logger('jira:history', `Fetching all tickets for store ${storeNum}`, 'debug'); + const results = await getJiraTicketsForStore(storeNum); + ticketKeys = results.map(t => t.key); + } else { + const components = arg2.split(',').map(c => c.trim().toLowerCase()); + logger('jira:history', `Fetching tickets for components: ${components.join(', ')}`, 'debug'); + + for (const comp of components) { + const results = await getJiraTicketsForComponentWithStore(storeNum, comp); + ticketKeys = ticketKeys.concat(results.map(t => t.key)); + } + } + + ticketKeys = [...new Set(ticketKeys)]; // dedup across components if any + + // Limit to most recent + if (ticketKeys.length > MAX_TICKETS) { + ticketKeys = ticketKeys.slice(0, MAX_TICKETS); + } + + if (ticketKeys.length === 0) { + reply += 'No matching Jira tickets found.\n'; + } else { + logger('jira:history', `Fetching details for ${ticketKeys.length} tickets in parallel`, 'debug'); + + const ticketPromises = ticketKeys.map(async (key) => { + try { + const fullTicket = await jira.getTicket(key); + const aiSummary = await summarizeJiraTicket(fullTicket).catch(() => + "**Reported Problem:** No details available.\n**Steps Taken:** No information recorded." + ); + + const fields = fullTicket.fields || {}; + const statusEmoji = getStatusEmoji(fields.status?.name); + const component = fields.components?.[0]?.name || '—'; + const assignee = fields.assignee?.displayName || '**Unassigned**'; + const days = calculateDaysOpen(fields.created, fields.resolved || fields.updated); + + return { + key, + summary: fields.summary || '—', + statusEmoji, + status: fields.status?.name || '—', + component, + assignee, + days, + aiSummary: aiSummary.substring(0, 800) // Limit summary length + }; + } catch (err) { + logger('jira:history', `Failed to process ticket ${key}: ${err.message}`, 'warn'); + return null; + } + }); + + const ticketResults = (await Promise.all(ticketPromises)).filter(Boolean); + + // Build reply with length control + let currentLength = reply.length; + + for (const t of ticketResults) { + let ticketBlock = `**${t.key}** - ${t.summary}\n\n`; + ticketBlock += `${t.aiSummary}\n\n`; + ticketBlock += `${t.statusEmoji} **Status:** ${t.status} • Component: ${t.component}\n`; + ticketBlock += `Assigned: ${t.assignee} • Open for: ${t.days} days\n\n`; + ticketBlock += `---\n\n`; + + // Check if adding this ticket would exceed safe limit + if (currentLength + ticketBlock.length > 6500) { + reply += `\n**... and ${ticketResults.length - ticketResults.indexOf(t)} more tickets.**\n`; + reply += `Use /jiraHistory ${storeNum} for full list.\n`; + break; + } + + reply += ticketBlock; + currentLength += ticketBlock.length; + } + + // Common Issues Analysis (only if we have enough tickets and space) + if (ticketResults.length >= 3 && currentLength < 6000) { + reply += `**Common Issues Across These Tickets:**\n`; + const common = await analyzeCommonIssues(ticketResults).catch(() => + "Unable to identify common patterns at this time." + ); + reply += `${common}\n`; + } + } + + reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + await bot.say('markdown', reply.trim()); + + } catch (err) { + logger('jira:history', `Error processing jiraHistory: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error retrieving Jira history: ${err.message}`); + } +} + +// (helpers imported from jiraService for consistency with jiraTicket etc.) \ No newline at end of file diff --git a/commands/jiraPoll.js b/commands/jiraPoll.js new file mode 100644 index 0000000..959c1f3 --- /dev/null +++ b/commands/jiraPoll.js @@ -0,0 +1,90 @@ +// src/commands/jiraPoll.js +// +// /jirapoll — run the hourly Jira poller ON DEMAND. Same code +// path as the cron job. Enriches any unlabeled +// matching tickets with a phone/av snapshot comment, +// labels them, and posts the standard summary to +// JIRA_POLLER_ROOM_ID if any were enriched. Also +// replies in the invoking chat with the counts so +// you get immediate feedback. +// +// /jirapoll prime — run in PRIME mode (labels every matching ticket +// without enriching or notifying). Equivalent to a +// one-off JIRA_POLLER_PRIME_ON_START=true restart. +// +// Auth model +// The command is `mutating: true` in the registry, so hitting the +// HTTP path requires HTTP_API_TOKEN. From Webex chat any user who +// can talk to the bot can run it — same trust model as the other +// mutating chat commands (`offboarduser`, `webexhost`, etc.). +// +// Concurrency +// `pollNewTickets` uses Jira labels for idempotency, so overlapping +// invocations are safe (each ticket can only be enriched once). If +// two people trigger `/jirapoll` at the same second, they'll each +// process disjoint slices of the label-race — no double comments. + +import { pollNewTickets } from '../services/jiraPollerService.js'; +import { extractRequester, describeRequester } from '../utils/requester.js'; +import { logger } from '../utils/logger.js'; + +export async function handleJiraPoll(bot, trigger) { + const requester = extractRequester(trigger); + const args = trigger.args || []; + const query = trigger.query || {}; + const primeArg = (args[0] || query.mode || '').toLowerCase(); + const isPrime = primeArg === 'prime'; + + logger('jira:poll:cmd', `Requested by ${describeRequester(requester)}${isPrime ? ' (PRIME mode)' : ''}`); + + // Acknowledge immediately — a full poll can run 15-30s at N=7 or + // 60s+ at N=50, and the operator shouldn't stare at a blank chat. + const ackLines = [ + isPrime + ? '⏳ Running Jira poller in **PRIME mode** — will label matching tickets without enriching…' + : '⏳ Running Jira poller on demand — this may take up to a minute for a full 50-ticket batch…', + ]; + await bot.say('markdown', ackLines.join('\n')); + + const startedAt = Date.now(); + let result; + try { + result = await pollNewTickets({ prime: isPrime }); + } catch (err) { + logger('jira:poll:cmd', `Poll failed: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Poll failed: \`${err.message}\``); + return; + } + + const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); + + if (isPrime) { + const primed = result.primed ?? 0; + const skipped = result.skipped ?? 0; + await bot.say( + 'markdown', + `✅ **Prime pass complete** (${elapsedSec}s)\n` + + `Labeled **${primed}** ticket(s) as \`bot-enriched\` without enrichment.` + + (skipped ? ` ${skipped} labeling failure(s) — check logs.` : ''), + ); + return; + } + + const enriched = result.enriched ?? 0; + const skipped = result.skipped ?? 0; + const tokens = result.tokensUsed ?? 0; + + const lines = [`✅ **Poll complete** (${elapsedSec}s, ${tokens} AI tokens)`]; + lines.push(`Enriched: **${enriched}**, skipped: **${skipped}**`); + if (enriched > 0 && process.env.JIRA_POLLER_ROOM_ID) { + // Note the standard summary that already went to the configured + // room so the invoker knows where the per-ticket detail lives. + lines.push(''); + lines.push(`_Per-ticket detail posted to the configured summary room._`); + } else if (enriched === 0) { + lines.push(''); + lines.push('_Nothing to enrich right now._'); + } + + await bot.say('markdown', lines.join('\n')); +} diff --git a/commands/jiraTicket.js b/commands/jiraTicket.js new file mode 100644 index 0000000..0cf1c81 --- /dev/null +++ b/commands/jiraTicket.js @@ -0,0 +1,59 @@ +// src/commands/jiraTicket.js +import jira from '../integrations/jira/JiraClient.js'; +import { summarizeJiraTicket } from '../services/jiraSummarizer.js'; +import { logger } from '../utils/logger.js'; +import { getStatusEmoji, calculateDaysOpen } from '../services/jiraService.js'; + +export async function handleJiraTicket(bot, trigger) { + logger('jira:ticket', 'Handler entered', 'debug'); + + // Support both Webex (args) and HTTP (query) calls + const query = trigger.query || {}; + const args = trigger.args || []; + + // Get ticket key from args or query param + const key = (args[0] || query.key || query.ticket || query.id || '') + .trim() + .toUpperCase(); + + if (!key || !/^[A-Z]+-\d+$/.test(key)) { + const usage = '**Jira Ticket Usage:**\n' + + '`/jiraTicket SS-3029` → Fetch ticket with AI summary\n\n' + + 'Also works via HTTP: `?key=SS-3029`'; + + await bot.say('markdown', usage); + return; + } + + logger('jira:ticket', `Fetching ticket ${key}`, 'debug'); + + try { + const ticket = await jira.getTicket(key); + + const aiSummary = await summarizeJiraTicket(ticket).catch(() => + "**Reported Problem:** No details available.\n**Steps Taken:** No information recorded.\n**Final Resolution:** No resolution documented." + ); + + const fields = ticket.fields || {}; + const statusEmoji = getStatusEmoji(fields.status?.name); + const component = fields.components?.[0]?.name || '—'; + const assignee = fields.assignee?.displayName || '**Unassigned**'; + const days = calculateDaysOpen(fields.created, fields.resolved || fields.updated); + + let reply = `**${key}** - ${fields.summary || '—'}\n\n`; + reply += `${aiSummary}\n\n`; + reply += `${statusEmoji} **Status:** ${fields.status?.name || '—'} • Component: ${component}\n`; + reply += `Assigned: ${assignee} • Open for: ${days} days\n\n`; + reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + + await bot.say('markdown', reply.trim()); + + logger('jira:ticket', `Successfully returned ticket ${key}`, 'debug'); + + } catch (err) { + logger('jira:ticket', `Error fetching ticket ${key}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error retrieving ticket **${key}**: ${err.message}`); + } +} + +// (helpers imported from jiraService for DRY / consistency) \ No newline at end of file diff --git a/commands/offboardUser.js b/commands/offboardUser.js new file mode 100644 index 0000000..57afa1f --- /dev/null +++ b/commands/offboardUser.js @@ -0,0 +1,369 @@ +// src/commands/offboardUser.js +// +// /offboarduser — guided offboarding flow. +// +// 1. Resolves the Webex person by email (via WebexClient so the call shares +// the bot's auth mutex + 401-retry path). +// 2. Lists matching MDM CORP devices for the same email. +// 3. Posts a confirmation adaptive card with everything that will happen. +// 4. On confirm, runs in parallel: +// - Revokes every Webex OAuth authorization for the user +// (POST /authorizations + DELETE /authorizations/{id}) +// - Enterprise-wipes each MDM CORP device. +// Each step's outcome is reported individually in the success message. +// +// Note on "Hide from directory search": +// The Webex `Hide from search` setting is Control Hub-only — it is NOT +// exposed via the People or SCIM 2.0 APIs (confirmed by Cisco docs and +// community). We therefore do not promise it in the card or success +// message, and instead include a hint that operators must toggle it +// manually in Control Hub if needed. +import { logger } from '../utils/logger.js'; +import webex from '../integrations/webex/WebexClient.js'; +import { findDevicesByEmail, enterpriseWipe } from '../integrations/mdmcorp/client.js'; +import { pendingOffboards } from '../utils/pendingOffboards.js'; +import { extractRequester, describeRequester } from '../utils/requester.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +// Distinguish "your service app is missing a scope / role" from generic errors +// so the operator can act on the message. +function explainWebexAdminError(err) { + const status = err?.response?.status; + const apiMsg = + err?.response?.data?.message || + err?.response?.data?.errors?.[0]?.description || + err?.message || + String(err); + + if (status === 401 || status === 403) { + return ( + `${apiMsg} (HTTP ${status}). Verify the Webex service app has the ` + + `\`identity:tokens_read\` and \`identity:tokens_write\` scopes and that ` + + `the authorizing admin has Full / User / Device Admin role.` + ); + } + return status ? `${apiMsg} (HTTP ${status})` : apiMsg; +} + +/** + * Revoke every Webex OAuth authorization belonging to a user. Deleting a + * refresh token revokes all access tokens issued from it, so this effectively + * kicks the user out of every signed-in client. + * + * Always returns a structured result instead of throwing — the caller wants to + * report partial outcomes side-by-side with the device wipes. + * + * @param {string} personId — Webex personId from /v1/people + * @returns {Promise<{ok: boolean, attempted: number, succeeded: number, failed: Array<{authorizationId: string, error: string}>, error?: string}>} + */ +export async function revokeUserAuthorizations(personId) { + let items; + try { + const list = await webex.listAuthorizations(personId); + items = Array.isArray(list?.items) ? list.items : []; + } catch (err) { + const reason = explainWebexAdminError(err); + return { ok: false, attempted: 0, succeeded: 0, failed: [], error: reason }; + } + + if (items.length === 0) { + return { ok: true, attempted: 0, succeeded: 0, failed: [] }; + } + + const results = await Promise.allSettled( + items.map((a) => webex.deleteAuthorization(a.id)), + ); + + const failed = []; + let succeeded = 0; + results.forEach((r, i) => { + if (r.status === 'fulfilled') { + succeeded += 1; + } else { + failed.push({ + authorizationId: items[i].id, + error: explainWebexAdminError(r.reason), + }); + } + }); + + return { + ok: failed.length === 0, + attempted: items.length, + succeeded, + failed, + }; +} + +function renderTokenRevocationLine(result) { + if (result.error) { + return `❌ Webex token revocation failed: ${result.error}`; + } + if (result.attempted === 0) { + return '• No active Webex authorizations found to revoke'; + } + if (result.failed.length === 0) { + return `✅ Revoked ${result.succeeded} Webex authorization${result.succeeded === 1 ? '' : 's'}`; + } + return ( + `⚠️ Revoked ${result.succeeded}/${result.attempted} Webex authorizations; ` + + `${result.failed.length} failed (first: ${result.failed[0].error})` + ); +} + +async function runWipesInParallel(devices) { + const labelled = devices.map((dev) => ({ + id: dev.id || dev.SerialNumber || dev.Uuid || dev.DeviceId, + name: dev.DeviceFriendlyName || dev.SerialNumber || dev.id || 'Unknown', + })); + + const settled = await Promise.allSettled( + labelled.map(({ id }) => enterpriseWipe(id)), + ); + + return settled.map((r, i) => { + const { name } = labelled[i]; + if (r.status === 'fulfilled') return `✅ ${name}`; + const msg = r.reason?.message || String(r.reason); + return `❌ ${name} (${msg})`; + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public entry points used by index.js +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Run the actual offboard work once the user clicks "Confirm Offboard" on + * the adaptive card. + * + * @param {object} bot - webex-node-bot-framework bot instance + * @param {object} offboardData - value previously stored in pendingOffboards + * @param {string} [_roomId] - retained for signature compatibility; the + * framework's `bot` is already room-scoped + * and bot.say() routes to its own room. + * Passing a 3rd positional arg into bot.say + * gets concatenated via util.format and + * leaks into the message body. + * @param {object} [requester] - { email, displayName, source } for audit log + */ +export async function applyOffboardConfirmation(bot, offboardData, _roomId, requester) { + logger( + 'offboard:audit', + `CONFIRMED offboard for ${offboardData.email} by ${describeRequester(requester)}`, + ); + + // Run Webex token revocation in parallel with MDM wipes — they're + // independent and we want to minimize wall-clock time on a destructive op. + const tokenPromise = offboardData.webexUserId + ? revokeUserAuthorizations(offboardData.webexUserId) + : Promise.resolve({ + ok: false, + attempted: 0, + succeeded: 0, + failed: [], + error: 'No Webex personId stored with offboard card', + }); + + const wipePromise = Array.isArray(offboardData.mdmDevices) && offboardData.mdmDevices.length > 0 + ? runWipesInParallel(offboardData.mdmDevices) + : Promise.resolve(null); + + const [tokenResult, wipeLines] = await Promise.all([tokenPromise, wipePromise]); + + // Build the user-facing summary + const tokenLine = renderTokenRevocationLine(tokenResult); + const wipeBlock = wipeLines === null + ? '• No MDM CORP devices found to wipe' + : `**MDM CORP device wipes:**\n${wipeLines.join('\n')}`; + + const successMsg = + `✅ **Offboard completed for ${offboardData.email}**\n\n` + + `${tokenLine}\n\n` + + `${wipeBlock}\n\n` + + `_Reminder: \`Hide from search\` is Control Hub-only and is **not** ` + + `toggled automatically. Set it manually in Control Hub > Users > ${offboardData.email} ` + + `> Security > Hide from search if required._`; + + await bot.say('markdown', successMsg); + + // Audit footer — final outcome captured for log scraping + logger( + 'offboard:audit', + `COMPLETED offboard for ${offboardData.email}: ` + + `tokens=${tokenResult.succeeded}/${tokenResult.attempted}` + + `${tokenResult.error ? ' (error)' : ''}, ` + + `wipes=${wipeLines === null ? 0 : wipeLines.filter((l) => l.startsWith('✅')).length}` + + `/${wipeLines === null ? 0 : wipeLines.length}`, + ); +} + +/** + * Cancel the pending offboard card with a user-visible confirmation message. + * + * Note: `_roomId` is intentionally unused — see applyOffboardConfirmation for + * the bot.say(..., roomId) footgun explanation. + */ +export async function cancelOffboardCard(bot, offboardData, _roomId, requester) { + await bot.say( + 'markdown', + `❌ Offboard cancelled for ${offboardData.email}. No changes were made.`, + ); + logger( + 'offboard:audit', + `CANCELLED offboard for ${offboardData.email} by ${describeRequester(requester)}`, + ); +} + +export async function handleOffboardUser(bot, trigger) { + logger('offboard:user', 'Handler entered'); + + const args = trigger.args || []; + const query = trigger.query || {}; + const email = (args[0] || query.email || query.user || '').trim().toLowerCase(); + + if (!email || !email.includes('@')) { + await bot.say('markdown', '**Usage:** `/offboardUser user@domain.com`'); + return; + } + + const requester = extractRequester(trigger); + + logger( + 'offboard:audit', + `REQUESTED offboard card for ${email} by ${describeRequester(requester)}`, + ); + + try { + const user = await webex.findPersonByEmail(email); + if (!user) { + await bot.say('markdown', `❌ No Webex user found for **${email}**.`); + return; + } + + const mdmDevices = await findDevicesByEmail(email); + + const cardId = `offboard-${Date.now()}`; + + pendingOffboards.set(cardId, { + email, + webexUserId: user.id, + webexUserDisplayName: user.displayName || email, + mdmDevices, + roomId: trigger.roomId || trigger.message?.roomId, + requester, + }); + + let deviceList = 'No devices found.'; + if (mdmDevices.length > 0) { + deviceList = mdmDevices.map((d, i) => { + const model = d.Model || d.DeviceReportedName || 'Unknown Model'; + const serial = d.SerialNumber || d.Udid || d.id || '—'; + return `${i+1}. ${model} • SN: ${serial}`; + }).join('\n'); + } + + const adaptiveCard = { + type: "AdaptiveCard", + version: "1.3", + body: [ + { + type: "TextBlock", + text: "⚠️ OFFBOARD USER CONFIRMATION", + weight: "Bolder", + size: "Large", + color: "Attention" + }, + { + type: "ColumnSet", + columns: [ + { + type: "Column", + width: "auto", + items: [{ + type: "Image", + url: user.avatar || "https://www.webex.com/content/dam/wbx/us/images/icon/avatar-placeholder.png", + size: "medium", + style: "person" + }] + }, + { + type: "Column", + width: "stretch", + items: [ + { type: "TextBlock", text: `**${user.displayName || email}**`, wrap: true }, + { type: "TextBlock", text: `Email: ${email}`, wrap: true, size: "Small" }, + user.title ? { type: "TextBlock", text: `Title: ${user.title}`, wrap: true, size: "Small" } : null, + user.department ? { type: "TextBlock", text: `Department: ${user.department}`, wrap: true, size: "Small" } : null + ].filter(Boolean) + } + ] + }, + { + type: "TextBlock", + text: `**MDM CORP devices to be wiped (${mdmDevices.length}):**`, + weight: "Bolder", + spacing: "Medium" + }, + { + type: "TextBlock", + text: deviceList, + wrap: true, + size: "Small" + }, + { + type: "TextBlock", + text: "**This will:**", + weight: "Bolder", + spacing: "Medium" + }, + { + type: "TextBlock", + text: + "• Revoke all of the user's active Webex OAuth authorizations " + + "(signs them out of every Webex client)\n" + + "• Enterprise-wipe every listed MDM CORP device", + wrap: true, + color: "Attention" + }, + { + type: "TextBlock", + text: + "ℹ️ `Hide from search` is **not** part of this action — it is a " + + "Control Hub-only setting and is not exposed via any Webex API. " + + "Set it manually in Control Hub if your offboarding policy requires it.", + wrap: true, + size: "Small", + isSubtle: true, + }, + ].filter(Boolean), + actions: [ + { + type: "Action.Submit", + title: "✅ Confirm Offboard", + data: { action: "confirm_offboard", cardId } + }, + { + type: "Action.Submit", + title: "❌ Cancel", + data: { action: "cancel_offboard", cardId } + } + ] + }; + + await bot.say({ + markdown: "Please review and confirm the offboard action:", + attachments: [{ + contentType: "application/vnd.microsoft.card.adaptive", + content: adaptiveCard + }] + }); + + } catch (err) { + logger('offboard:user', `Error during lookup for ${email}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error looking up user **${email}**: ${err.message}`); + } +} diff --git a/commands/phoneStatus.js b/commands/phoneStatus.js new file mode 100644 index 0000000..43964a6 --- /dev/null +++ b/commands/phoneStatus.js @@ -0,0 +1,64 @@ +// src/commands/phoneStatus.js +// +// Chat + HTTP entry point for /phonestatus. The heavy rendering lives in +// services/renderers/phoneStatusRenderer.js so the Jira poller can emit +// the same markdown (see services/jiraPollerService.js). This handler +// stays thin: parse args, call the collector, hand data to the renderer, +// respond. + +import { collectPhoneStatus } from '../services/phoneService.js'; +import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js'; +import { logger } from '../utils/logger.js'; + +export async function handlePhoneStatus(bot, trigger) { + logger('phone:status', 'Handler entered', 'debug'); + + // Support both Webex (args) and HTTP (query) calls + const query = trigger.query || {}; + const args = trigger.args || []; + + let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; + + const isDetailed = (args[1]?.toLowerCase() === 'detailed') || + (query.mode === 'detailed') || + (query.detailed === 'true' || query.detailed === true); + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + const errorMsg = 'Please provide a 2–4 digit store number.\n' + + 'Example: `/phonestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../phonestatus?storeNum=782`'; + await bot.say('markdown', errorMsg); + return; + } + + logger('phone:status', `Collecting phone status for store ${storeNum}`, 'debug'); + + try { + const data = await collectPhoneStatus(storeNum); + if (!data) throw new Error('collectPhoneStatus returned undefined'); + + // JSON alt-output path (kept in the handler because it bypasses + // markdown rendering entirely — no shared renderer applies). + if (query.format === 'json' || (args[1] && args[1].toLowerCase() === 'json')) { + const jsonPayload = { + store: storeNum, + mainNumber: data.locationMainNumber, + timezone: (data.telephonyProfile && data.telephonyProfile.timeZone) || null, + person: data.person ? { displayName: data.person.displayName, phoneNumbers: data.person.phoneNumbers } : null, + timestamp: new Date().toISOString(), + }; + await bot.say('markdown', '```json\n' + JSON.stringify(jsonPayload, null, 2) + '\n```'); + return; + } + + const reply = renderPhoneStatusMarkdown(data, { + storeNum, + detailed: isDetailed, + footer: true, + }); + await bot.say('markdown', reply || 'No data available.'); + + } catch (err) { + logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `Error collecting phone status: ${err.message}`); + } +} diff --git a/commands/provisionDect.js b/commands/provisionDect.js new file mode 100644 index 0000000..cabea4e --- /dev/null +++ b/commands/provisionDect.js @@ -0,0 +1,397 @@ +// src/commands/provisionDect.js +import { + findDectNetworkForStore, + getDectProvisioningStatus, + addDectBasestation, + removeDectBasestation, + addDectHandset, + removeDectHandset, + generateDectAccessCode +} from '../services/phoneService.js'; +import { logger } from '../utils/logger.js'; + +function buildProvisioningCard(storeNum, network, basestations = [], handsets = []) { + const padded = String(storeNum).padStart(4, '0'); + const networkName = network?.name || `Store ${padded}`; + + const baseFacts = [ + { title: 'Basestations', value: String(basestations.length) }, + { title: 'Handsets', value: String(handsets.length) }, + { title: 'Location', value: network?.locationName || network?.location?.name || '—' } + ]; + + const baseList = basestations.length + ? basestations.map(b => ({ + type: 'TextBlock', + text: `• ${b.mac || '—'} | ${b.status || 'unknown'} | IP: ${b.ipAddress || '—'}`, + size: 'Small' + })) + : [{ type: 'TextBlock', text: 'None', size: 'Small', color: 'Attention' }]; + + const handsetList = handsets.length + ? handsets.map(h => ({ + type: 'TextBlock', + text: `• ${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || '—'} (${h.name || ''}) @ Base ${h.baseMac || h.baseStationId || 'unassigned'}`, + size: 'Small' + })) + : [{ type: 'TextBlock', text: 'None', size: 'Small', color: 'Attention' }]; + + // Choices for remove (use ids) - will be turned into checkboxes + const baseChoices = basestations.map(b => ({ + title: `${b.mac} (${b.status})`, + value: b.id + })); + const handsetChoices = handsets.map(h => ({ + title: `${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || '—'}`, + value: h.id + })); + + return { + type: 'AdaptiveCard', + version: '1.3', + body: [ + { + type: 'TextBlock', + text: `DECT Provisioning - Store ${padded}`, + weight: 'Bolder', + size: 'Large' + }, + { + type: 'TextBlock', + text: `Network: ${networkName}`, + size: 'Medium' + }, + { + type: 'FactSet', + facts: baseFacts + }, + { + type: 'TextBlock', + text: 'Current Basestations', + weight: 'Bolder', + spacing: 'Medium' + }, + ...baseList, + { + type: 'TextBlock', + text: 'Current Handsets', + weight: 'Bolder', + spacing: 'Medium' + }, + ...handsetList, + { + type: 'TextBlock', + text: 'Add Basestation(s) (comma/space separated MACs)', + weight: 'Bolder', + spacing: 'Medium' + }, + { + type: 'Input.Text', + id: 'baseMacs', + placeholder: '001122334455, AABBCCDDEEFF' + }, + { + type: 'TextBlock', + text: 'Add Handset', + weight: 'Bolder', + spacing: 'Medium' + }, + { + type: 'TextBlock', + text: 'Remove - use checkboxes (multi-select supported). Confirmation will be required.', + weight: 'Bolder', + spacing: 'Medium' + }, + { + type: 'Input.ChoiceSet', + id: 'removeBases', + isMultiSelect: true, + style: 'expanded', + choices: baseChoices.length ? baseChoices : [{ title: 'None', value: '' }] + }, + { + type: 'Input.ChoiceSet', + id: 'removeHandsets', + isMultiSelect: true, + style: 'expanded', + choices: handsetChoices.length ? handsetChoices : [{ title: 'None', value: '' }] + }, + + ], + actions: [ + { + type: 'Action.Submit', + title: '➕ Add Basestation(s)', + data: { action: 'add-bases', storeNumber: String(storeNum) } + }, + { + type: 'Action.Submit', + title: '➕ Add Handset', + data: { action: 'add-handset', storeNumber: String(storeNum) } + }, + { + type: 'Action.Submit', + title: '🗑 Remove Selected Basestations (will confirm)', + data: { action: 'remove-bases', storeNumber: String(storeNum) } + }, + { + type: 'Action.Submit', + title: '🗑 Remove Selected Handsets (will confirm)', + data: { action: 'remove-handsets', storeNumber: String(storeNum) } + }, + { + type: 'Action.Submit', + title: '🔄 Refresh Status', + data: { action: 'refresh', storeNumber: String(storeNum) } + } + ] + }; +} + + + +export async function handleProvisionDect(bot, trigger) { + logger('phone:provision', 'Handler entered'); + + const args = trigger.args || []; + const query = trigger.query || {}; + let storeNum = (args[0] || query.storeNum || query.store || query.s || '').trim(); + + if (!storeNum || !/^\d{3,4}$/.test(storeNum)) { + await bot.say('markdown', '**Usage:** `/provision-dect 1234` (3-4 digit store number)\nNetwork must already exist as "Store XXXX".'); + return; + } + + logger('phone:provision', `Starting provisioning for store ${storeNum}`); + + try { + const status = await getDectProvisioningStatus(storeNum); + const { network, basestations, handsets } = status; + + if (!network) { + await bot.say('markdown', `❌ No DECT network found for Store ${storeNum} (looked up via 5-digit person). Ensure the network "Store ${storeNum.padStart(4, '0')}" exists.`); + return; + } + + const card = buildProvisioningCard(storeNum, network, basestations, handsets); + + await bot.say({ + markdown: `DECT Provisioning for Store ${storeNum}`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: card + }] + }); + } catch (err) { + logger('phone:provision', `Error in handler for ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error: ${err.message}`); + } +} + +// Handle attachment actions for provisioning +export async function handleDectProvisionAction(bot, trigger) { + const action = trigger.attachmentAction; + if (!action || !action.inputs) return; + + const inputs = action.inputs; + const actionType = inputs.action; + const storeNumber = inputs.storeNumber; + if (!actionType || !storeNumber) return; + + const roomId = trigger.roomId || action.roomId; + logger('phone:provision', `Action ${actionType} for store ${storeNumber}`); + + try { + const status = await getDectProvisioningStatus(storeNumber); + let { network, basestations = [], handsets = [] } = status; + + if (!network) { + await bot.say('markdown', '❌ Network no longer found. Ignore any stale cards.', roomId); + return; + } + + const locationId = network.locationId || network.location?.id; + const networkId = network.id; + + // Remove the card message that was acted on (to prevent stale clicks) + // Use bot.censor which uses the bot's token and checks permissions + const messageId = trigger.attachmentAction.messageId; + if (messageId) { + try { + await bot.censor(messageId); + logger('phone:provision', `Removed stale provisioning card message ${messageId}`); + } catch (delErr) { + logger('phone:provision', `Could not remove previous card message: ${delErr.message}`, 'warn'); + } + } + + let resultTitle = ''; + let resultMsg = ''; + let isRemoveAction = false; + let selectedForConfirm = []; + + const staleNote = '⚠️ This is a fresh updated view. Any previous DECT provisioning cards for this store are now stale — please ignore them.'; + + if (actionType === 'refresh') { + resultTitle = '✅ Status Refreshed'; + resultMsg = 'Current DECT status updated below.'; + } else if (actionType === 'add-bases') { + const macs = inputs.baseMacs || ''; + if (!macs) { + resultTitle = '⚠️ No MACs provided'; + resultMsg = 'Enter MAC(s) to add.'; + } else { + const results = await addDectBasestation(locationId, networkId, macs); + const added = results.filter(r => r.success).length; + const skipped = results.filter(r => r.alreadyExists).length; + const failed = results.filter(r => r.error).length; + resultTitle = '✅ Add Basestation(s)'; + resultMsg = `Added: ${added}, Skipped (exists): ${skipped}, Failed: ${failed}`; + } + } else if (actionType === 'add-handset') { + // Auto extension: always 5 + 4-digit padded store number (e.g. store 782 -> 50782) + const padded4 = String(storeNumber).padStart(4, '0'); + const ext = '5' + padded4; + const code = generateDectAccessCode(storeNumber); + // display name = extension (per requirements) + const res = await addDectHandset(locationId, networkId, { + displayName: ext, + accessCode: code + // no baseStationId + }); + if (res?.alreadyExists) { + resultTitle = 'ℹ️ Already exists'; + resultMsg = `Handset with access code or similar for extension ${ext} already present.`; + } else { + resultTitle = '✅ Handset Added'; + resultMsg = `Added handset for extension ${ext}. Access code: ${code} (per store rules). Refresh to see updated list.`; + } + } else if (actionType === 'remove-bases' || actionType === 'remove-handsets') { + isRemoveAction = true; + const isBase = actionType === 'remove-bases'; + let rawSelected = isBase ? inputs.removeBases : inputs.removeHandsets; + const selected = Array.isArray(rawSelected) ? rawSelected : (rawSelected ? [rawSelected] : []); + if (!selected || selected.length === 0) { + resultTitle = '⚠️ Nothing selected'; + resultMsg = 'Select items using checkboxes to remove.'; + } else { + // Send confirmation card instead of deleting immediately + const itemsList = selected.map(id => { + if (isBase) { + const b = basestations.find(x => x.id === id); + return b ? `${b.mac} (${b.status})` : id; + } else { + const h = handsets.find(x => x.id === id); + const label = h ? `${h.index ? h.index + '-' : ''}${h.extension || h.accessCode || ''}` : id; + return label; + } + }).join(', '); + + const confirmAction = isBase ? 'confirm-remove-bases' : 'confirm-remove-handsets'; + const confirmCard = { + type: 'AdaptiveCard', + version: '1.3', + body: [ + { + type: 'TextBlock', + text: `⚠️ CONFIRM DELETE`, + weight: 'Bolder', + size: 'Large', + color: 'Attention' + }, + { + type: 'TextBlock', + text: `You are about to permanently remove the following ${isBase ? 'basestation(s)' : 'handset(s)'} for Store ${storeNumber}:`, + wrap: true + }, + { + type: 'TextBlock', + text: itemsList, + wrap: true + }, + { + type: 'TextBlock', + text: 'This action cannot be undone. The provisioning card will be refreshed after.', + wrap: true, + color: 'Attention' + } + ], + actions: [ + { + type: 'Action.Submit', + title: '✅ Yes, Delete', + data: { action: confirmAction, storeNumber, selected } + }, + { + type: 'Action.Submit', + title: '❌ Cancel', + data: { action: 'cancel-remove', storeNumber } + } + ] + }; + + await bot.say({ + markdown: 'Confirm removal', + attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: confirmCard }] + }, roomId); + return; // don't send the main card yet + } + } else if (actionType === 'confirm-remove-bases' || actionType === 'confirm-remove-handsets') { + const isBase = actionType === 'confirm-remove-bases'; + let rawSelected = inputs.selected; + const selected = Array.isArray(rawSelected) ? rawSelected : (rawSelected ? [rawSelected] : []); + const results = []; + for (const id of selected) { + try { + if (isBase) { + await removeDectBasestation(locationId, networkId, id); + results.push(`✅ base ${id}`); + } else { + await removeDectHandset(locationId, networkId, id); + results.push(`✅ handset ${id}`); + } + } catch (e) { + results.push(`❌ ${id}: ${e.message}`); + } + } + resultTitle = '✅ Remove Completed'; + resultMsg = results.join('\n'); + } else if (actionType === 'cancel-remove') { + resultTitle = 'ℹ️ Remove cancelled'; + resultMsg = 'No changes made.'; + } else { + resultTitle = 'ℹ️ Unknown action'; + resultMsg = actionType; + } + + // Re-fetch fresh status + const fresh = await getDectProvisioningStatus(storeNumber); + + // Build fresh card and add banners (stale note + result) + const updatedCard = buildProvisioningCard(storeNumber, fresh.network, fresh.basestations, fresh.handsets); + updatedCard.body.unshift({ + type: 'TextBlock', + text: `${resultTitle}\n${resultMsg}`, + weight: 'Bolder', + color: resultTitle.includes('✅') ? 'Good' : 'Attention' + }); + updatedCard.body.unshift({ + type: 'TextBlock', + text: staleNote, + wrap: true, + color: 'Attention' + }); + + await bot.say({ + markdown: resultTitle, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: updatedCard + }] + }, roomId); + + } catch (err) { + logger('phone:provision', `Action ${actionType} error for ${storeNumber}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Action failed: ${err.message}. Previous card may be stale.`, roomId); + } +} \ No newline at end of file diff --git a/commands/registry.js b/commands/registry.js new file mode 100644 index 0000000..1755c36 --- /dev/null +++ b/commands/registry.js @@ -0,0 +1,110 @@ +// src/commands/registry.js +// +// Single source of truth for every bot command. +// +// Both dispatch paths in index.js consume this registry: +// - Webex chat: framework.hears(/.*/, …) parses the text, then runs the +// entry whose name (or alias) matches the first token. +// - HTTP API: app.get('/:command', …) looks up the same registry and runs +// the entry's handler against a captured-output mock bot. +// +// Adding a new command means appending one entry below. Both Webex and HTTP +// pick it up automatically (subject to `mutating` / `http` flags). + +import { handleHelp } from './help.js'; +import { handleAvStatus } from './avStatus.js'; +import { handlePhoneStatus } from './phoneStatus.js'; +import { handleProvisionDect } from './provisionDect.js'; +import { handleWoHistory } from './woHistory.js'; +import { handleWoSummary } from './woSummary.js'; +import { handleWoAttachments } from './woAttachments.js'; +import { handleJiraHistory } from './jiraHistory.js'; +import { handleJiraTicket } from './jiraTicket.js'; +import { handleJiraPoll } from './jiraPoll.js'; +import { handleProvisionVc } from './vcProvision.js'; +import { handleVcMonitor } from './vcMonitor.js'; +import { handleOffboardUser } from './offboardUser.js'; +import { handleWebexHost } from './webexHost.js'; +import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js'; +import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js'; +import { handleTestDevicesByModel } from './testDevicesByModel.js'; + +/** + * Each entry: + * - name: canonical name (lowercase, no leading slash). + * - aliases: additional names that route to the same handler. + * - handler: async (bot, trigger) => void + * - mutating: when true, the HTTP /:command path requires HTTP_API_TOKEN. + * - http: defaults to true. Set false for commands that only make + * sense over Webex chat (e.g. `help`). + */ +export const commands = [ + { name: 'help', handler: handleHelp, mutating: false, http: false }, + + { name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false }, + { name: 'phonestatus', handler: handlePhoneStatus, mutating: false }, + { name: 'wohistory', handler: handleWoHistory, mutating: false }, + { name: 'wosummary', handler: handleWoSummary, mutating: false }, + { name: 'woattachments', handler: handleWoAttachments, mutating: false }, + { name: 'jirahistory', handler: handleJiraHistory, mutating: false }, + { name: 'jiraticket', handler: handleJiraTicket, mutating: false }, + // /jirapoll [prime] — trigger the hourly Jira poller on demand. Writes + // Jira comments + labels, so mutating: true (HTTP path gated by + // HTTP_API_TOKEN). See commands/jiraPoll.js for the behavior contract. + { name: 'jirapoll', aliases: ['pollnow'], handler: handleJiraPoll, mutating: true }, + + { name: 'provision-dect', aliases: ['provisiondect'], handler: handleProvisionDect, mutating: true }, + { name: 'provision-vc', aliases: ['vcprovision'], handler: handleProvisionVc, mutating: true }, + { name: 'vcmonitor', handler: handleVcMonitor, mutating: true }, + { name: 'offboarduser', handler: handleOffboardUser, mutating: true }, + { name: 'webexhost', handler: handleWebexHost, mutating: true }, + // bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment, + // which requires a Webex roomId. The HTTP mock trigger has no roomId, so + // the command is chat-only; the runtime guard in the handler backstops this. + { name: 'bulkavstatuscsv', handler: handleBulkAvStatusCSV, mutating: true, http: false }, + { name: 'bulkavswitchcsv', handler: handleBulkAvSwitchCSV, mutating: true }, + { name: 'devicesbymodel', handler: handleTestDevicesByModel, mutating: true }, +]; + +const byName = new Map(); +for (const cmd of commands) { + const all = [cmd.name, ...(cmd.aliases || [])].map(n => n.toLowerCase()); + for (const key of all) { + if (byName.has(key)) { + // Fail loud on misconfiguration — silent alias overwrite is the kind of + // drift this registry is designed to prevent. + throw new Error(`Duplicate command registration: "${key}"`); + } + byName.set(key, cmd); + } +} + +/** + * Look up a command by canonical name or alias. Case-insensitive. + * @returns {{name: string, handler: Function, mutating: boolean, http?: boolean, aliases?: string[]} | null} + */ +export function getCommand(name) { + if (!name) return null; + return byName.get(String(name).toLowerCase().trim()) || null; +} + +/** Names + aliases of commands exposed over HTTP (used for /:command 404 hints). */ +export const ALL_HTTP_COMMAND_KEYS = (() => { + const keys = []; + for (const cmd of commands) { + if (cmd.http === false) continue; + keys.push(cmd.name, ...(cmd.aliases || [])); + } + return keys.sort(); +})(); + +/** Set of names + aliases that require HTTP_API_TOKEN on the HTTP path. */ +export const MUTATING_COMMAND_KEYS = (() => { + const keys = new Set(); + for (const cmd of commands) { + if (!cmd.mutating) continue; + keys.add(cmd.name); + for (const a of cmd.aliases || []) keys.add(a); + } + return keys; +})(); diff --git a/commands/testDevicesByModel.js b/commands/testDevicesByModel.js new file mode 100644 index 0000000..ca8506a --- /dev/null +++ b/commands/testDevicesByModel.js @@ -0,0 +1,55 @@ +// src/commands/testDevicesByModel.js +import { logger } from '../utils/logger.js'; +import { fetchAllPages } from '../integrations/meraki/client.js'; // or use direct axios if you prefer +import botClient from '../integrations/webex/BotClient.js'; + +export async function handleTestDevicesByModel(bot, trigger) { + const roomId = trigger.roomId || trigger.message?.roomId; + if (!roomId) return; + + await bot.say('markdown', '🔄 Testing **Devices Overview by Model** endpoint...'); + + try { + const orgId = process.env.MERAKI_ORG_ID; + + logger('test-by-model', `Calling /organizations/${orgId}/devices/overview/byModel`); + + // This endpoint does not support pagination — it returns all models in one call + const response = await fetchAllPages(`/organizations/${orgId}/devices/overview/byModel`); // or direct axios.get + + const counts = response.counts || response || []; + + logger('test-by-model', `Received counts for ${counts.length} models`); + + let reply = `**Devices Overview by Model**\n\n`; + reply += `Total models returned: ${counts.length}\n\n`; + + let table = '| Model | Count |\n|-------|-------|\n'; + counts.forEach(item => { + table += `| ${item.model || '—'} | ${item.total || 0} |\n`; + }); + + reply += table; + + await bot.say('markdown', reply); + + // Optional: Also send as CSV for easy download + let csv = 'Model,Count\n'; + counts.forEach(item => { + csv += `"${item.model || '—'}",${item.total || 0}\n`; + }); + + const buffer = Buffer.from(csv, 'utf8'); + await botClient.sendWithAttachment( + roomId, + buffer, + `Devices_By_Model_${new Date().toISOString().slice(0,10)}.csv`, + 'text/csv', + '📊 Devices Overview by Model CSV attached' + ); + + } catch (err) { + logger('test-by-model', `Error: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error calling overview by model: ${err.message}`); + } +} \ No newline at end of file diff --git a/commands/unknownCommand.js b/commands/unknownCommand.js new file mode 100644 index 0000000..5b955e3 --- /dev/null +++ b/commands/unknownCommand.js @@ -0,0 +1,10 @@ +// In src/commands/unknownCommand.js +import { handleHelp } from "./help.js"; +import { logger } from '../utils/logger.js'; + +export async function handleUnknown(bot, trigger) { + logger('command:unknown', `Triggered for: ${trigger.message?.text || 'no text'}`); + await bot.say({ markdown: `Sorry, I don't understand **${trigger.message.text}**.`}); + handleHelp(bot, trigger) + +} \ No newline at end of file diff --git a/commands/vcMonitor.js b/commands/vcMonitor.js new file mode 100644 index 0000000..a79a1d6 --- /dev/null +++ b/commands/vcMonitor.js @@ -0,0 +1,71 @@ +// src/commands/vcMonitor.js +// Thin handler for /vcMonitor [start|stop|status] [Full|Limited|...] +// First iteration: on-demand start of packet capture via cloud xAPI ExtendedLogging. + +import { + startPacketCapture, + stopPacketCapture, + getExtendedLoggingStatus +} from '../services/vcMonitorService.js'; +import { logger } from '../utils/logger.js'; + +export async function handleVcMonitor(bot, trigger) { + logger('vc-monitor', 'Handler entered'); + + const args = trigger.args || []; + const query = trigger.query || {}; + const serialNumber = args[0]?.trim() || query.serial || query.serialNumber || query.s || query.serialnum; + + if (!serialNumber) { + logger('vc-monitor', 'No serial number provided – showing usage', 'warn'); + + await bot.say('markdown', + '**Usage:** `/vcMonitor [action] [PacketDumpType]`\n\n' + + 'Actions (default = start):\n' + + '• `start` (or omit) — begin capture\n' + + '• `stop` — stop the current extended logging session\n' + + '• `status` — show current Logging.ExtendedLogging.Mode + PacketDump\n\n' + + 'PacketDumpType (only for start):\n' + + '• `Full` — everything including RTP media (~3 min)\n' + + '• `Limited` — non-RTP/signaling only (~10 min)\n' + + '• `FullRotate` — rolling capture (last ~1h worth)\n\n' + + 'Examples:\n' + + '• `/vcMonitor FOC2419NTN2` — start Full capture (on demand)\n' + + '• `/vcMonitor FOC2419NTN2 Limited`\n' + + '• `/vcMonitor FOC2419NTN2 stop`\n' + + '• `/vcMonitor FOC2419NTN2 status`\n\n' + + '**After capture:** Download the full log bundle from Control Hub (Issues & Diagnostics → System Logs). The .pcap files are inside the bundle.' + ); + return; + } + + // Parse action + optional dump type + let action = (args[1] || query.action || 'start').toLowerCase().trim(); + let dumpType = args[2] || query.type || query.packetDump || query.dump || 'Full'; + + // Convenience: allow `/vcMonitor SERIAL Full` to mean start Full + if (['full', 'limited', 'fullrotate', 'none'].includes(action)) { + dumpType = action; + action = 'start'; + } + + logger('vc-monitor', `Command for ${serialNumber}: action=${action} dumpType=${dumpType}`); + + try { + if (action === 'start' || action === 'begin' || action === 'capture') { + await startPacketCapture(bot, serialNumber, dumpType); + } else if (action === 'stop' || action === 'end') { + await stopPacketCapture(bot, serialNumber); + } else if (action === 'status' || action === 'show' || action === 'state') { + await getExtendedLoggingStatus(bot, serialNumber); + } else { + await bot.say('markdown', + `Unknown action "${action}". Supported: start, stop, status.\n\n` + + `Example: \`/vcMonitor ${serialNumber} start Full\`` + ); + } + } catch (error) { + // Service already sent user-facing error + logged. Just make sure we don't crash the handler. + logger('vc-monitor', `Handler caught error for ${serialNumber}/${action}: ${error.message}`, 'error'); + } +} diff --git a/commands/vcProvision.js b/commands/vcProvision.js new file mode 100644 index 0000000..2081cf4 --- /dev/null +++ b/commands/vcProvision.js @@ -0,0 +1,44 @@ +// src/commands/vcProvision.js +// Command: /provision-vc (supports org selection for multi-org) +import { provisionVideoDevice } from '../services/vcProvisionService.js'; +import { logger } from '../utils/logger.js'; + +export async function handleProvisionVc(bot, trigger) { + logger('vc-provision', 'Handler entered'); + + const args = trigger.args || []; + const serialNumber = args[0]?.trim(); + const orgIdentifier = args[1]?.trim(); + + if (!serialNumber) { + logger('vc-provision', 'No serial number provided – showing usage', 'warn'); + + await bot.say('markdown', + '**Usage:** `/provision-vc [org]`\n\n' + + 'Example: `/provision-vc FOC2419NTN2`\n' + + 'Example (specific org): `/provision-vc FOC2419NTN2 todd`\n\n' + + 'org can be a partial name ("american", "todd", "snyder") or full org ID.\n\n' + + 'This will:\n' + + '• Apply standard configuration\n' + + '• Generate and install a new certificate from DigiCert\n' + + '• Add backdoor admin account (`monitor`)\n' + + '• Reboot the device' + ); + return; + } + + logger('vc-provision', `Starting provisioning for serial: ${serialNumber}${orgIdentifier ? ` (org: ${orgIdentifier})` : ''}`); + + try { + // Pass the bot instance so the service can send live progress updates + await provisionVideoDevice(bot, serialNumber, orgIdentifier); + } catch (error) { + logger('vc-provision', `Provisioning failed for ${serialNumber}: ${error.message}`, 'error'); + + // Fallback error message to user + await bot.say('markdown', + `❌ **Provisioning failed for ${serialNumber}**\n\n` + + `${error.message}` + ); + } +} \ No newline at end of file diff --git a/commands/webexHost.js b/commands/webexHost.js new file mode 100644 index 0000000..9df84cf --- /dev/null +++ b/commands/webexHost.js @@ -0,0 +1,632 @@ +// src/commands/webexHost.js +// +// /webexhost — report whether a user holds any Webex Meetings host +// license on the configured site. If they don't, post +// a confirmation card to assign the default host +// license (set via WEBEX_HOST_LICENSE_ID). +// /webexhost list — discovery helper: list every Webex Meetings license +// on the configured site with `id`, `name`, and free +// seats. Marks the one currently set as +// WEBEX_HOST_LICENSE_ID so the operator knows which +// one auto-assignment will use. +// +// Host detection model +// The Webex license-assignment API explicitly states that the host vs +// attendee distinction on a site is **determined by whether the user holds a +// meeting license whose `siteUrl` matches the site**. There is no separate +// "host flag" exposed via any Webex API (the SiteUrlsRequest contract only +// accepts `attendee` — see wxc_sdk source). So: +// - "is host" ↔ user.licenses ∩ { licenses on this site } ≠ ∅ +// - "make host" ↔ PATCH /v1/licenses/users adding the configured license +// +// Required scopes on the Webex service app +// spark-admin:licenses_read — list/inspect licenses +// spark-admin:people_read — already used elsewhere +// spark-admin:people_write — apply license assignments +// +// Configuration +// WEBEX_HOST_SITE_URL defaults to "aeo2go.webex.com" +// WEBEX_HOST_LICENSE_ID no default — must be set before auto-assignment +// can run. Use `/webexhost list` to discover IDs. +import { logger } from '../utils/logger.js'; +import webex from '../integrations/webex/WebexClient.js'; +import { pendingHostAssigns } from '../utils/pendingHostAssigns.js'; +import { extractRequester, describeRequester } from '../utils/requester.js'; + +const SITE_URL = process.env.WEBEX_HOST_SITE_URL || 'aeo2go.webex.com'; + +// ───────────────────────────────────────────────────────────────────────────── +// License cache +// ───────────────────────────────────────────────────────────────────────────── +// Org-wide license list rarely changes (seats consumed do, but the license +// IDs/names don't). We cache for LICENSE_CACHE_TTL_MS to avoid re-fetching on +// every /webexhost invocation. The cache holds *only the licenses on the +// configured site* — that's all this command cares about. + +const LICENSE_CACHE_TTL_MS = 5 * 60 * 1000; +let _siteLicenseCache = null; +let _siteLicenseCacheAt = 0; + +// Per-license assignee cache: { licenseId -> { personIds: Set, fetchedAt: number } }. +// We need this because the per-person `licenses` field on /v1/people/{id} is +// unreliable for service-app tokens (returns empty for users who demonstrably +// hold licenses — confirmed via /webexhost debug). The authoritative source +// is the reverse-lookup endpoint `/v1/licenses/{id}?includeAssignedTo=user`, +// which is paginated and can be expensive for large licenses (the org's +// Webex Meetings Suite has ~3200 users → ~11 pages). Caching the full +// assignee set per license keeps subsequent /webexhost calls instant. +const ASSIGNEE_CACHE_TTL_MS = 30 * 60 * 1000; +const _assigneeCache = new Map(); + +export function _resetLicenseCacheForTests() { + _siteLicenseCache = null; + _siteLicenseCacheAt = 0; + _assigneeCache.clear(); +} + +async function getSiteLicenses() { + if (_siteLicenseCache && Date.now() - _siteLicenseCacheAt < LICENSE_CACHE_TTL_MS) { + return _siteLicenseCache; + } + const data = await webex.listLicenses(); + const items = Array.isArray(data?.items) ? data.items : []; + _siteLicenseCache = items.filter((l) => l.siteUrl === SITE_URL); + _siteLicenseCacheAt = Date.now(); + return _siteLicenseCache; +} + +// Returns a Set for the given license. Walks every page on first +// call (1–N HTTP requests depending on license size); subsequent calls within +// ASSIGNEE_CACHE_TTL_MS are an O(1) Map lookup. +async function getLicenseAssignees(licenseId) { + const cached = _assigneeCache.get(licenseId); + if (cached && Date.now() - cached.fetchedAt < ASSIGNEE_CACHE_TTL_MS) { + return cached.personIds; + } + const users = await webex.listLicenseAssignees(licenseId); + const personIds = new Set(); + for (const u of users) { + if (u && typeof u.id === 'string') personIds.add(u.id); + } + _assigneeCache.set(licenseId, { personIds, fetchedAt: Date.now() }); + return personIds; +} + +// Reverse-lookup detection: for each site license, check whether personId is +// in its assignee list (cached). Runs in parallel across licenses. Returns +// the licenses the user actually holds, plus any per-license errors so the +// caller can surface them. +async function findHeldSiteLicensesViaAssignees(personId, siteLicenses) { + const results = await Promise.allSettled( + siteLicenses.map(async (l) => { + const assignees = await getLicenseAssignees(l.id); + return { license: l, isHeld: assignees.has(personId) }; + }), + ); + + const held = []; + const errors = []; + results.forEach((r, i) => { + if (r.status === 'fulfilled') { + if (r.value.isHeld) held.push(r.value.license); + } else { + errors.push({ license: siteLicenses[i], error: explainWebexAdminError(r.reason) }); + } + }); + + return { held, errors }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function explainWebexAdminError(err) { + const status = err?.response?.status; + const apiMsg = + err?.response?.data?.message || + err?.response?.data?.errors?.[0]?.description || + err?.message || + String(err); + + if (status === 401 || status === 403) { + return ( + `${apiMsg} (HTTP ${status}). The service-app token is missing a required ` + + `admin scope (likely \`spark-admin:people_write\` for assignments, or ` + + `\`spark-admin:people_read\` if read fields are empty). ` + + `**Important:** adding scopes at developer.webex.com is NOT enough — ` + + `existing refresh tokens preserve their original scope set. ` + + `You must (1) update scopes on the service app, (2) re-authorize the app ` + + `for the org as a Full / User Admin, then (3) re-bootstrap ` + + `\`tokens/webex-service-tokens.json\` with a fresh access_token + refresh_token.` + ); + } + return status ? `${apiMsg} (HTTP ${status})` : apiMsg; +} + +function seatsFreeFor(license) { + const total = Number(license.totalUnits ?? 0); + const used = Number(license.consumedUnits ?? 0); + return Math.max(0, total - used); +} + +function intersectLicenses(personLicenseIds, siteLicenses) { + const owned = new Set(personLicenseIds || []); + return siteLicenses.filter((l) => owned.has(l.id)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// /webexhost list — discovery helper +// ───────────────────────────────────────────────────────────────────────────── + +async function handleListLicenses(bot) { + let licenses; + try { + licenses = await getSiteLicenses(); + } catch (err) { + await bot.say('markdown', `❌ Failed to list licenses for \`${SITE_URL}\`: ${explainWebexAdminError(err)}`); + return; + } + + if (licenses.length === 0) { + await bot.say( + 'markdown', + `No Webex Meetings licenses found on \`${SITE_URL}\`. ` + + `Verify the site URL via \`WEBEX_HOST_SITE_URL\` env var.`, + ); + return; + } + + const configuredId = process.env.WEBEX_HOST_LICENSE_ID; + + const lines = [`**Webex Meetings licenses on \`${SITE_URL}\`:**`, '']; + for (const l of licenses) { + const free = seatsFreeFor(l); + const marker = configuredId && l.id === configuredId + ? ' ← currently configured as `WEBEX_HOST_LICENSE_ID`' + : ''; + lines.push(`- **${l.name}** — ${free}/${l.totalUnits} seats free \n id: \`${l.id}\`${marker}`); + } + + if (!configuredId) { + lines.push( + '', + `_Set \`WEBEX_HOST_LICENSE_ID=\` in \`.env\` to enable ` + + `\`/webexhost \` auto-assignment._`, + ); + } + + await bot.say('markdown', lines.join('\n')); +} + +// ───────────────────────────────────────────────────────────────────────────── +// /webexhost debug — diagnostic dump +// ───────────────────────────────────────────────────────────────────────────── + +async function handleDebug(bot, email) { + let searchHit, fullPerson, siteLicenses; + try { + searchHit = await webex.findPersonByEmail(email); + } catch (err) { + await bot.say('markdown', `❌ \`findPersonByEmail\` failed: ${explainWebexAdminError(err)}`); + return; + } + if (!searchHit) { + await bot.say('markdown', `❌ No Webex user found for **${email}**.`); + return; + } + try { + [fullPerson, siteLicenses] = await Promise.all([ + webex.getPerson(searchHit.id), + getSiteLicenses(), + ]); + } catch (err) { + await bot.say('markdown', `❌ Debug fetch failed: ${explainWebexAdminError(err)}`); + return; + } + + const searchLics = Array.isArray(searchHit.licenses) ? searchHit.licenses : null; + const fullLics = Array.isArray(fullPerson.licenses) ? fullPerson.licenses : []; + const peopleApiOwned = intersectLicenses(fullLics, siteLicenses); + + // Always run the assignee-scan path too, so debug shows both signals for + // direct comparison. Errors are reported per-license rather than failing + // the whole debug call. + const assigneeStart = Date.now(); + const assigneeResult = await findHeldSiteLicensesViaAssignees(searchHit.id, siteLicenses); + const assigneeMs = Date.now() - assigneeStart; + + const lines = [ + `### 🔍 \`/webexhost debug ${email}\``, + '', + `**Person id:** \`${searchHit.id}\``, + `**Display name:** ${searchHit.displayName || '(none)'}`, + '', + '**People API signal**', + `- Search-endpoint \`licenses\`: ${searchLics === null ? '(field absent)' : `\`[${searchLics.length}]\` items`}`, + `- GET /people/{id} \`licenses\`: \`[${fullLics.length}]\` items`, + `- \`siteUrls\` on person record: ${Array.isArray(fullPerson.siteUrls) ? `\`${JSON.stringify(fullPerson.siteUrls)}\`` : '(field absent)'}`, + '', + '**Assignee-scan signal** (authoritative reverse lookup)', + `- Per-license assignee lists fetched / cache-hit in ${assigneeMs}ms`, + `- Held on site: ${assigneeResult.held.length > 0 + ? assigneeResult.held.map((l) => `\`${l.name}\``).join(', ') + : '_none_'}`, + ]; + if (assigneeResult.errors.length > 0) { + lines.push(`- ⚠️ Errors:`); + for (const e of assigneeResult.errors) { + lines.push(` - \`${e.license.name}\`: ${e.error}`); + } + } + lines.push('', `**Site licenses on \`${SITE_URL}\` (${siteLicenses.length}):**`); + for (const l of siteLicenses) { + const heldByPeople = fullLics.includes(l.id); + const heldByAssignee = assigneeResult.held.some((h) => h.id === l.id); + const marker = heldByPeople && heldByAssignee + ? ' ✅ HELD (both signals agree)' + : heldByAssignee + ? ' ✅ HELD (assignee scan only — People API silent)' + : heldByPeople + ? ' ⚠️ HELD (People API only — assignee scan disagrees)' + : ''; + lines.push(`- \`${l.id}\` — \`${l.name}\`${marker}`); + } + lines.push(''); + const finalVerdict = peopleApiOwned.length > 0 || assigneeResult.held.length > 0 + ? `**Verdict:** host (bot will report "already a host")` + : `**Verdict:** not a host (bot will offer assignment card)`; + lines.push(finalVerdict); + + if (searchLics !== null && searchLics.length !== fullLics.length) { + lines.push(''); + lines.push( + `ℹ️ Search endpoint and GET /people/{id} returned different license counts ` + + `(${searchLics.length} vs ${fullLics.length}).`, + ); + } + + await bot.say('markdown', lines.join('\n')); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public entry points used by index.js (attachmentAction routing) +// ───────────────────────────────────────────────────────────────────────────── + +// NOTE: `bot` here is the framework-provided per-room bot — its own `bot.say` +// already routes to the originating room. Do NOT pass roomId as a third arg +// to bot.say: under the hood it uses util.format which would append the +// roomId string to the markdown body. The roomId param is retained in the +// signature for future use (audit / cross-room routing) but is intentionally +// not threaded into bot.say. +export async function applyHostAssignConfirmation(bot, data, _roomId, requester) { + logger( + 'webexhost:audit', + `CONFIRMED host assign for ${data.email} (license: ${data.licenseName}) ` + + `by ${describeRequester(requester)}`, + ); + + let response; + try { + response = await webex.assignLicensesToUser({ + personId: data.personId, + licenses: [{ id: data.licenseId, operation: 'add' }], + }); + } catch (err) { + const msg = explainWebexAdminError(err); + await bot.say( + 'markdown', + `❌ Failed to assign host license to **${data.displayName}**: ${msg}`, + ); + logger('webexhost:audit', `FAILED host assign for ${data.email}: ${msg}`, 'error'); + return; + } + + const grantedIds = new Set(response?.licenses || []); + const pendingIds = new Set(response?.pendingLicenses || []); + + let line; + let outcome; + if (grantedIds.has(data.licenseId)) { + line = + `✅ **${data.displayName}** is now a host on \`${SITE_URL}\` ` + + `(license: \`${data.licenseName}\`).`; + outcome = 'granted'; + } else if (pendingIds.has(data.licenseId)) { + line = + `⏳ License assignment is **pending acceptance** by ` + + `**${data.displayName}** (external user). License: \`${data.licenseName}\`.`; + outcome = 'pending'; + } else { + line = + `⚠️ License \`${data.licenseName}\` did not appear in the response. ` + + `Webex returned: \`${JSON.stringify(response)}\`. Verify in Control Hub.`; + outcome = 'unconfirmed'; + } + + await bot.say('markdown', line); + logger( + 'webexhost:audit', + `COMPLETED host assign for ${data.email}: outcome=${outcome}, ` + + `license=${data.licenseId}`, + ); +} + +export async function cancelHostAssignCard(bot, data, _roomId, requester) { + await bot.say( + 'markdown', + `❌ Host license assignment cancelled for **${data.displayName}**. No changes were made.`, + ); + logger( + 'webexhost:audit', + `CANCELLED host assign for ${data.email} by ${describeRequester(requester)}`, + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main entry — /webexhost +// ───────────────────────────────────────────────────────────────────────────── + +export async function handleWebexHost(bot, trigger) { + const args = trigger.args || []; + const query = trigger.query || {}; + + const firstArg = (args[0] || query.action || query.email || query.user || '') + .toString() + .trim(); + + // /webexhost list (or licenses) — discovery subcommand + if (firstArg.toLowerCase() === 'list' || firstArg.toLowerCase() === 'licenses') { + return handleListLicenses(bot); + } + + // /webexhost debug — dumps the raw Webex payload for diagnosing + // detection mismatches (e.g. license shows in Control Hub but the bot says + // "not a host"). Shows both the search-endpoint result and the full + // GET /v1/people/{id} response, plus the site licenses + intersection. + if (firstArg.toLowerCase() === 'debug') { + const debugEmail = ((args[1] || query.email || query.user || '').toString().trim()).toLowerCase(); + if (!debugEmail || !debugEmail.includes('@')) { + await bot.say('markdown', '**Usage:** `/webexhost debug `'); + return; + } + return handleDebug(bot, debugEmail); + } + + const email = firstArg.toLowerCase(); + if (!email || !email.includes('@')) { + await bot.say( + 'markdown', + '**Usage:**\n' + + '- `/webexhost ` — check host status on the configured site; offer to assign if missing.\n' + + '- `/webexhost list` — list available Webex Meetings licenses on the site.\n' + + '- `/webexhost debug ` — dump raw Webex payload for troubleshooting.', + ); + return; + } + + const requester = extractRequester(trigger); + + logger( + 'webexhost:audit', + `REQUESTED host check for ${email} on ${SITE_URL} by ${describeRequester(requester)}`, + ); + + let user; + let siteLicenses; + try { + [user, siteLicenses] = await Promise.all([ + webex.findPersonByEmail(email), + getSiteLicenses(), + ]); + } catch (err) { + const msg = explainWebexAdminError(err); + await bot.say('markdown', `❌ Lookup failed for **${email}**: ${msg}`); + logger('webexhost:audit', `FAILED host check for ${email}: ${msg}`, 'error'); + return; + } + + if (!user) { + await bot.say('markdown', `❌ No Webex user found for **${email}**.`); + return; + } + + if (siteLicenses.length === 0) { + await bot.say( + 'markdown', + `⚠️ No Webex Meetings licenses are configured on \`${SITE_URL}\`. ` + + `Run \`/webexhost list\` to confirm — or verify \`WEBEX_HOST_SITE_URL\`.`, + ); + return; + } + + // Try the People API first — it's a single cheap call. For some + // tenants/scopes it actually returns `licenses`. If it does and any of + // them match the site, we can short-circuit before paying for the slower + // assignee scan. + let fullPerson; + try { + fullPerson = await webex.getPerson(user.id); + } catch (err) { + const msg = explainWebexAdminError(err); + await bot.say('markdown', `❌ Couldn't read user record for **${email}**: ${msg}`); + return; + } + const personLicenseIds = Array.isArray(fullPerson.licenses) ? fullPerson.licenses : []; + let ownedSiteLicenses = intersectLicenses(personLicenseIds, siteLicenses); + let detectionPath = 'people-api'; + let assigneeErrors = []; + + // Fall back to the authoritative reverse-lookup if the People API was + // silent. Service-app tokens routinely return person.licenses=[] even for + // users who do hold licenses — confirmed against Josh Babir via the debug + // subcommand. The reverse lookup uses the assignment data Cisco actually + // maintains. + if (ownedSiteLicenses.length === 0) { + detectionPath = 'assignee-scan'; + try { + const r = await findHeldSiteLicensesViaAssignees(user.id, siteLicenses); + ownedSiteLicenses = r.held; + assigneeErrors = r.errors; + } catch (err) { + const msg = explainWebexAdminError(err); + await bot.say( + 'markdown', + `❌ Couldn't verify license assignment for **${email}**: ${msg}`, + ); + return; + } + } + + if (assigneeErrors.length > 0) { + logger( + 'webexhost:audit', + `Assignee scan for ${email} had ${assigneeErrors.length} per-license error(s); ` + + `first: ${assigneeErrors[0].license.name} → ${assigneeErrors[0].error}`, + 'warn', + ); + } + + // Case A — already a host: report and stop. + if (ownedSiteLicenses.length > 0) { + const lines = ownedSiteLicenses.map((l) => `• \`${l.name}\``).join('\n'); + await bot.say( + 'markdown', + `✅ **${user.displayName || email}** is **already a host** on \`${SITE_URL}\`.\n\n` + + `**Current meeting license(s) on this site:**\n${lines}`, + ); + logger( + 'webexhost:audit', + `COMPLETED host check for ${email}: already-host (${ownedSiteLicenses.map((l) => l.id).join(',')}) ` + + `via=${detectionPath}`, + ); + return; + } + + // Case B — not a host, no WEBEX_HOST_LICENSE_ID configured: tell the operator + // how to fix the config and skip the card. + const configuredId = process.env.WEBEX_HOST_LICENSE_ID; + if (!configuredId) { + await bot.say( + 'markdown', + `⚠️ **${user.displayName || email}** is **not a host** on \`${SITE_URL}\`, ` + + `but \`WEBEX_HOST_LICENSE_ID\` is not configured.\n\n` + + `Run \`/webexhost list\` to see the available licenses, then set ` + + `\`WEBEX_HOST_LICENSE_ID=\` in \`.env\` and reload.`, + ); + return; + } + + // Case C — configured license ID doesn't match any license on this site. + const targetLicense = siteLicenses.find((l) => l.id === configuredId); + if (!targetLicense) { + await bot.say( + 'markdown', + `❌ \`WEBEX_HOST_LICENSE_ID\` is set but doesn't match any license on \`${SITE_URL}\`. ` + + `Run \`/webexhost list\` to find a valid id.`, + ); + return; + } + + // Case D — configured license has no free seats. + const free = seatsFreeFor(targetLicense); + if (free <= 0) { + await bot.say( + 'markdown', + `❌ Cannot assign \`${targetLicense.name}\` to **${user.displayName || email}** ` + + `— the license has 0/${targetLicense.totalUnits} seats free. ` + + `Pick a different license via \`WEBEX_HOST_LICENSE_ID\` or free a seat first.`, + ); + return; + } + + // Case E — happy path: post the confirmation card. + const cardId = `hostassign-${Date.now()}`; + pendingHostAssigns.set(cardId, { + email, + personId: user.id, + displayName: user.displayName || email, + licenseId: targetLicense.id, + licenseName: targetLicense.name, + roomId: trigger.roomId || trigger.message?.roomId, + requester, + }); + + const adaptiveCard = { + type: 'AdaptiveCard', + version: '1.3', + body: [ + { + type: 'TextBlock', + text: '➕ ASSIGN WEBEX HOST LICENSE', + weight: 'Bolder', + size: 'Large', + color: 'Accent', + }, + { + type: 'ColumnSet', + columns: [ + { + type: 'Column', + width: 'auto', + items: [{ + type: 'Image', + url: user.avatar || 'https://www.webex.com/content/dam/wbx/us/images/icon/avatar-placeholder.png', + size: 'medium', + style: 'person', + }], + }, + { + type: 'Column', + width: 'stretch', + items: [ + { type: 'TextBlock', text: `**${user.displayName || email}**`, wrap: true }, + { type: 'TextBlock', text: `Email: ${email}`, wrap: true, size: 'Small' }, + user.title ? { type: 'TextBlock', text: `Title: ${user.title}`, wrap: true, size: 'Small' } : null, + user.department ? { type: 'TextBlock', text: `Department: ${user.department}`, wrap: true, size: 'Small' } : null, + ].filter(Boolean), + }, + ], + }, + { + type: 'FactSet', + spacing: 'Medium', + facts: [ + { title: 'Site', value: SITE_URL }, + { title: 'Current host status', value: 'Not a host' }, + { title: 'License to assign', value: targetLicense.name }, + { title: 'Seats remaining', value: `${free} / ${targetLicense.totalUnits}` }, + ], + }, + { + type: 'TextBlock', + text: + `Confirming will PATCH \`/v1/licenses/users\` to grant ` + + `**${user.displayName || email}** the \`${targetLicense.name}\` ` + + `license, making them a host on \`${SITE_URL}\`.`, + wrap: true, + spacing: 'Medium', + }, + ], + actions: [ + { + type: 'Action.Submit', + title: '✅ Confirm Assign', + data: { action: 'confirm_host_assign', cardId }, + }, + { + type: 'Action.Submit', + title: '❌ Cancel', + data: { action: 'cancel_host_assign', cardId }, + }, + ], + }; + + await bot.say({ + markdown: `**${user.displayName || email}** is not a host on \`${SITE_URL}\`. Review and confirm assignment:`, + attachments: [{ + contentType: 'application/vnd.microsoft.card.adaptive', + content: adaptiveCard, + }], + }); +} diff --git a/commands/woAttachments-web.js b/commands/woAttachments-web.js new file mode 100644 index 0000000..f815aea --- /dev/null +++ b/commands/woAttachments-web.js @@ -0,0 +1,37 @@ +// src/commands/woAttachments-web.js +import { listWorkOrderAttachments } from '../integrations/serviceChannel/attachments.js'; +import { logger } from '../utils/logger.js'; + +export async function handleWoAttachmentsWeb(woId) { + logger('wo-attachments-web', `Handling web request for attachments on WO ${woId}`); + + try { + const attachments = await listWorkOrderAttachments(woId); + + const result = { + success: true, + woId: parseInt(woId), + count: attachments.length, + attachments: attachments.map(att => ({ + id: att.Id, + fileName: att.Name || `attachment_${att.Id}`, + contentType: att.ContentType || 'application/octet-stream', + downloadUri: att.Uri + })) + }; + + logger('wo-attachments-web', + `Successfully returned ${attachments.length} attachments for WO ${woId}`); + + return result; + + } catch (err) { + logger('wo-attachments-web', + `Error fetching attachments for WO ${woId}: ${err.message}`, 'error'); + + return { + success: false, + error: err.message + }; + } +} \ No newline at end of file diff --git a/commands/woAttachments.js b/commands/woAttachments.js new file mode 100644 index 0000000..01acfc9 --- /dev/null +++ b/commands/woAttachments.js @@ -0,0 +1,67 @@ +// src/commands/woAttachments.js +import { Readable } from 'stream'; +import { listWorkOrderAttachments, downloadAttachmentById } from '../integrations/serviceChannel/attachments.js'; +import botClient from '../integrations/webex/BotClient.js'; +import { logger } from '../utils/logger.js'; + +export async function handleWoAttachments(bot, trigger) { + logger('wo-attachments', 'Handler entered'); + + const args = trigger.args || []; + const woNumber = args[0]?.trim(); + + if (!woNumber || !/^\d+$/.test(woNumber)) { + logger('wo-attachments', 'Invalid or missing work order number', 'warn'); + await bot.reply(trigger.message, + 'Please provide a valid work order number after /woAttachments\n' + + '(e.g. `/woAttachments 345057944`)' + ); + return; + } + + logger('wo-attachments', `Processing attachments for work order ${woNumber}`); + + try { + const attachments = await listWorkOrderAttachments(woNumber); + + if (attachments.length === 0) { + logger('wo-attachments', `No attachments found for WO ${woNumber}`); + await bot.reply(trigger.message, `No attachments found for work order **${woNumber}**.`); + return; + } + + logger('wo-attachments', `Found ${attachments.length} attachments for WO ${woNumber}`); + await bot.reply(trigger.message, + `Found **${attachments.length}** attachment(s) for work order **${woNumber}**. Sending them now...` + ); + + for (const att of attachments) { + try { + const file = await downloadAttachmentById(woNumber, att.Id); + + await botClient.sendWithAttachment( + trigger.message.roomId, + file.buffer, + file.fileName, + file.contentType, + `📎 Attachment from WO ${woNumber}: ${file.fileName}` + ); + + logger('wo-attachments', `Successfully sent attachment: ${file.fileName}`); + + } catch (err) { + logger('wo-attachments', `Failed to send attachment ${att.Id || att.Name || 'unknown'}: ${err.message}`, 'error'); + await bot.reply(trigger.message, `❌ Failed to send attachment ${att.Id || att.Name || 'unknown'}`); + } + } + + await bot.reply(trigger.message, `Finished sending attachments for WO **${woNumber}**.`); + logger('wo-attachments', `Completed processing attachments for WO ${woNumber}`); + + } catch (err) { + logger('wo-attachments', `Error processing WO ${woNumber}: ${err.message}`, 'error'); + await bot.reply(trigger.message, + `Error processing attachments for work order ${woNumber}: ${err.message}` + ); + } +} \ No newline at end of file diff --git a/commands/woHistory.js b/commands/woHistory.js new file mode 100644 index 0000000..ec13827 --- /dev/null +++ b/commands/woHistory.js @@ -0,0 +1,65 @@ +// src/commands/woHistory.js +import { collectWoHistory } from '../services/woService.js'; +import { logger } from '../utils/logger.js'; + +export async function handleWoHistory(bot, trigger) { + logger('wo:history', 'Handler entered'); + + // Support both Webex (args) and HTTP (query) + const query = trigger.query || {}; + const args = trigger.args || []; + + let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s; + const modeArg = args[1]?.toLowerCase() || query.mode || ''; + + const isDetailed = modeArg === 'detailed' || query.detailed === 'true'; + + if (!storeNum || !/^\d{2,4}$/.test(storeNum)) { + const usage = '**Work Order History Usage:**\n' + + '`/woHistory 782` → Summary (last 15)\n' + + '`/woHistory 782 detailed` → Full list\n\n' + + 'Also works via HTTP: `?storeNum=782&mode=detailed`'; + + await bot.say('markdown', usage); + return; + } + + logger('wo:history', `Request for store ${storeNum} (${isDetailed ? 'detailed' : 'summary'} mode)`); + + try { + const data = await collectWoHistory(storeNum); + + let reply = `**Work Order History – Store ${storeNum}** (Audio Visual Trade, past 3 years)\n\n`; + + if (data.workOrders.length === 0) { + reply += 'No Audio Visual work orders found in the past 3 years.\n'; + } else { + // Show all in detailed mode, limit to 15 in summary + const displayWOs = isDetailed ? data.workOrders : data.workOrders.slice(0, 15); + + let grandTotal = 0; + + displayWOs.forEach(wo => { + reply += `**${wo.woNumber}** - ${wo.summary}\n`; + reply += `Status: ${wo.status} • Opened: ${wo.openedDate} • Total Cost: $${wo.totalInvoiceCost.toLocaleString()}\n\n`; + grandTotal += wo.totalInvoiceCost; + }); + + reply += `**Grand Total (AV Trade):** $${grandTotal.toLocaleString()}\n`; + + if (!isDetailed && data.workOrders.length > 15) { + reply += `\nShowing first 15 of ${data.workOrders.length} work orders. Use \`/woHistory ${storeNum} detailed\` for full list.\n`; + } + } + + reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + + await bot.say('markdown', reply.trim() || 'No data available.'); + + logger('wo:history', `Successfully returned ${data.workOrders.length} work orders for store ${storeNum}`); + + } catch (err) { + logger('wo:history', `Error processing woHistory for store ${storeNum}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error collecting work order history: ${err.message}`); + } +} \ No newline at end of file diff --git a/commands/woSummary.js b/commands/woSummary.js new file mode 100644 index 0000000..4529406 --- /dev/null +++ b/commands/woSummary.js @@ -0,0 +1,42 @@ +// src/commands/woSummary.js +import { collectWoSummary } from '../services/woService.js'; +import { logger } from '../utils/logger.js'; + +export async function handleWoSummary(bot, trigger) { + logger('wo:summary', 'Handler entered'); + + // Support both Webex (args) and HTTP (query) + const query = trigger.query || {}; + const args = trigger.args || []; + + // Get work order number from args or query param + const woNumber = (args[0] || query.woNumber || query.wo || query.workorder || query.id || '') + .trim(); + + if (!woNumber || !/^\d+$/.test(woNumber)) { + const usage = '**Work Order Summary Usage:**\n' + + '`/woSummary 123456` → Get detailed summary for a specific work order\n\n' + + 'Also works via HTTP: `?woNumber=123456`'; + + await bot.say('markdown', usage); + return; + } + + logger('wo:summary', `Fetching summary for work order ${woNumber}`); + + try { + const summary = await collectWoSummary(woNumber); + + let reply = `**Work Order Summary – ${woNumber}**\n\n`; + reply += `${summary || 'No summary data available.'}\n`; + reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + + await bot.say('markdown', reply.trim()); + + logger('wo:summary', `Successfully returned summary for WO ${woNumber}`); + + } catch (err) { + logger('wo:summary', `Error fetching summary for WO ${woNumber}: ${err.message}`, 'error'); + await bot.say('markdown', `❌ Error fetching work order summary for **${woNumber}**: ${err.message}`); + } +} \ No newline at end of file diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..907bbe1 --- /dev/null +++ b/config/index.js @@ -0,0 +1,8 @@ +// src/config/index.js +// +// Legacy compatibility shim (now mostly a no-op). +// The project has fully migrated to environment variables. +// Any remaining imports of this file will get an empty object. +// This file (and the config/ directory) can be removed in a future pass. + +export default {}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..562c59f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.9' + +services: + collabfinder: + build: . + container_name: collabfinder + restart: unless-stopped + ports: + - "1800:1800" + env_file: + - .env + volumes: + # Runtime data (persisted locally on the Docker host; written by non-root container user) + - ./logs:/app/logs + - ./storage:/app/storage + + # Rotating Webex Service App token (critical for backend operations + auto-refresh writes) + # - Keep this file ONLY locally (it is .gitignored and .dockerignored). + # - Provide a valid bootstrap copy on the HOST before first run. + # - App will read + write (refresh) it via the bind mount. + # - On some hosts you may need: chmod 666 config/webex-service-tokens.json (for non-root writes) + # - All other config via .env (loaded by env_file). + - ./config/webex-service-tokens.json:/app/config/webex-service-tokens.json + + # Required local files for full functionality (provide in your ./storage/ on host): + # - storage/aeoroots.cer (for /provision-vc certificate flows) + # + # Legacy config/config.json has been removed. All configuration is now via .env variables. + networks: + - collabnet + + # Optional: Add Redis later if you want better caching + # redis: + # image: redis:7-alpine + # container_name: collabfinder-redis + # restart: unless-stopped + # networks: + # - collabnet + +networks: + collabnet: + driver: bridge \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..faefc84 --- /dev/null +++ b/index.js @@ -0,0 +1,615 @@ +// src/index.js +import 'dotenv/config'; + +import { logger } from './utils/logger.js'; + +// ────────────────────────────────────────────── +// Integrations & Clients +// ────────────────────────────────────────────── +import { refreshMerakiNetworksCache } from './integrations/meraki/networks.js'; +import { refreshAtlasDevicesCache } from './integrations/atlas/devices.js'; +import { pendingOffboards } from './utils/pendingOffboards.js'; +import { requireApiToken, requireAuthForAllCommands } from './utils/httpAuth.js'; + +// ────────────────────────────────────────────── +// Commands +// ────────────────────────────────────────────── +// All command handlers live in commands/registry.js (single source of truth +// for both Webex chat and HTTP dispatch). Only the entries imported here are +// referenced directly by index.js — adaptive-card actions and the unknown- +// command fallback. +import { handleUnknown } from './commands/unknownCommand.js'; +import { handleDectProvisionAction } from './commands/provisionDect.js'; +import { + applyOffboardConfirmation, + cancelOffboardCard, +} from './commands/offboardUser.js'; +import { + applyHostAssignConfirmation, + cancelHostAssignCard, +} from './commands/webexHost.js'; +import { pendingHostAssigns } from './utils/pendingHostAssigns.js'; +import { extractRequester } from './utils/requester.js'; +import { + getCommand, + ALL_HTTP_COMMAND_KEYS, + MUTATING_COMMAND_KEYS, +} from './commands/registry.js'; + +// ────────────────────────────────────────────── +// Express setup +// ────────────────────────────────────────────── +import express from 'express'; + +const app = express(); +const PORT = process.env.SERVER_PORT || 1800; + +// Basic middleware +// Capture the raw request body during JSON parsing so downstream handlers can +// verify webhook signatures (e.g. a future ServiceChannel HMAC check on +// processServiceChannelWebhook). Previously this was attempted via a second +// `bodyParser.json({ verify })` after `express.json()`, but the first parser +// consumed the body so `req.rawBody` was never set. +app.use(express.json({ + limit: '5mb', + verify: (req, res, buf) => { req.rawBody = buf; } +})); +app.use(express.urlencoded({ extended: true })); + +// Support subpath proxy (e.g. /CollabSupport/ via NGINX) by stripping the prefix +// so internal routes like /phone/devices/build work whether prefix is passed through or stripped by proxy. +// This runs very early so static + all routes (including /:command and build endpoints) see clean paths. +app.use((req, res, next) => { + if (req.url.startsWith('/CollabSupport')) { + req.url = req.url.replace(/^\/CollabSupport/, '') || '/'; + } + next(); +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }); +}); + +// In your main app file (where you set up Express) +app.use(express.static('public')); // Serve files from /public folder + +// NOTE: There is intentionally no /bot HTTP route here. The webex-node-bot-framework +// is initialized without `webhookUrl`, so it connects to Webex over websockets +// (see lib/framework.js → "There was no webhookUrl specified so we will use websockets instead"). +// The previous `app.post('/bot', …)` was a no-op left over from a webhook-mode prototype +// and Webex never actually POSTed to it. If you ever switch back to webhook delivery, +// re-add the handler using `webhook(framework)` from 'webex-node-bot-framework/webhook' +// and configure `frameworkConfig.webhookUrl` + `webhookSecret`. + +// ======================== +// HTTP API ENDPOINTS - Dynamic Command Router (registry-driven) +// ======================== +// +// Both this `/:command` route and the Webex `framework.hears` block at the +// bottom of the file dispatch through the same registry (commands/registry.js). +// The registry's `mutating` flag drives whether HTTP_API_TOKEN is required. + +const commandAuth = requireApiToken({ scope: 'command' }); +const dataAuth = requireApiToken({ scope: 'data' }); + +// For read-only dashboard/data endpoints (/api/av/*, /av/devices/build/*, +// /phone/devices/build/*). Open by default; locks down only when the operator +// explicitly opts in with HTTP_API_REQUIRE_AUTH=true. +function dataAuthGate(req, res, next) { + if (!requireAuthForAllCommands()) return next(); + return dataAuth(req, res, next); +} + +// In your routes file (e.g. app.js or routes/av.js) +import { getAVDevicesForStore, getShapedDeviceData } from './services/avDeviceService.js'; + +app.get('/api/av/devices/:storeNumber', dataAuthGate, async (req, res) => { + const result = await getAVDevicesForStore(req.params.storeNumber); + res.json(result); +}); + +import { buildAVDevices } from './services/avDeviceBuilder.js'; +// Example: inside your AV router +// src/routes/av.js (or wherever your AV routes are) +app.get('/av/devices/build/:storeNumber', dataAuthGate, async (req, res) => { + const storeNumber = req.params.storeNumber; + + try { + const result = await buildAVDevices(storeNumber); // direct pass-through + res.json(result); + } catch (err) { + logger('av:route', `Build endpoint failed for ${storeNumber}: ${err.message}`, 'error'); + res.status(500).json({ + success: false, + message: err.message + }); + } +}); + +import { buildPhoneDevices } from './services/phoneDeviceBuilder.js'; +app.get('/phone/devices/build/:storeNumber', dataAuthGate, async (req, res) => { + const storeNumber = req.params.storeNumber; + + try { + const result = await buildPhoneDevices(storeNumber); + res.json(result); + } catch (err) { + logger('phone:route', `Build endpoint failed for ${storeNumber}: ${err.message}`, 'error'); + res.status(500).json({ + success: false, + message: err.message + }); + } +}); + +app.get('/api/av/device/:storeNumber/:identifier', dataAuthGate, async (req, res) => { + const result = await getShapedDeviceData(req.params.storeNumber, req.params.identifier); + res.json(result); +}); + +// Auth gate that runs before the dispatcher. Mutating commands always require +// a valid HTTP_API_TOKEN; everything else does too when HTTP_API_REQUIRE_AUTH +// is on. Unknown commands fall through to the dispatcher (which returns 404). +function commandAuthGate(req, res, next) { + const name = (req.params.command || '').toLowerCase().trim(); + const cmd = getCommand(name); + const isMutating = MUTATING_COMMAND_KEYS.has(name) || (cmd && cmd.mutating); + const needsAuth = isMutating || requireAuthForAllCommands(); + if (!needsAuth) return next(); + return commandAuth(req, res, next); +} + +app.get('/:command', commandAuthGate, async (req, res) => { + const name = req.params.command.toLowerCase().trim(); + const query = req.query; + + logger('http:endpoint', `Received HTTP request → ${name}`, 'debug'); + logger('http:endpoint', query, 'debug'); + + if (!name) { + return res.status(400).json({ error: 'Command is required (e.g. /avstatus)' }); + } + + const cmd = getCommand(name); + if (!cmd || cmd.http === false) { + return res.status(404).json({ + error: `Unknown command: ${name}`, + supported: ALL_HTTP_COMMAND_KEYS, + }); + } + + // Adapter so chat handlers (which expect a bot + trigger) work over HTTP. + // Handlers should read inputs from trigger.query in this mode. `source: + // 'http'` lets handlers branch on origin (e.g. for audit log lines). + const fakeTrigger = { + args: [], + query, + rawQuery: req.originalUrl, + source: 'http', + }; + + let output = ''; + const mockBot = { + say: async (formatOrPayload, message) => { + // Handlers call either bot.say('markdown', msg) or bot.say({markdown, attachments}) + if (typeof formatOrPayload === 'object' && formatOrPayload !== null) { + output = formatOrPayload.markdown || formatOrPayload.text || JSON.stringify(formatOrPayload); + } else { + output = message; + } + }, + }; + + try { + await cmd.handler(mockBot, fakeTrigger); + res.setHeader('Content-Type', 'text/markdown'); + res.send(output || '(No output generated)'); + } catch (err) { + logger('http:endpoint', `Error executing ${name}: ${err.message}`, 'error'); + res.status(500).json({ + error: 'Internal server error', + command: name, + message: err.message, + }); + } +}); + +// ────────────────────────────────────────────── +// Webex Framework +// ────────────────────────────────────────────── +import Framework from 'webex-node-bot-framework'; + +// The framework connects to Webex over websockets (it only registers a +// webhook when `webhookUrl` is set, which we intentionally don't). `app` is +// still passed in case any framework feature wants to mount a route, but no +// HTTP ingress is required for inbound events to reach the bot. +// +// `removeDeviceRegistrationsOnStart: true` issues a `DELETE /wdm/api/v1/devices` +// against the bot's WDM record set during framework startup, BEFORE registering +// the new device. This is the framework-blessed cure for the "Forbidden: User +// has excessive device registrations" error: every nodemon restart, every +// container redeploy, and every crash leaves the prior WDM registration alive +// on Cisco's side (TTL ~2h, hard cap ~100). Wiping them on each start is safe +// for a single-instance bot — if multi-instance deploys are ever needed, +// gate this on a `WEBEX_DEDUP_DEVICES_ON_START` env var instead. +const frameworkConfig = { + token: process.env.WEBEX_BOT_TOKEN, + app, + removeDeviceRegistrationsOnStart: true, +}; + +if (!frameworkConfig.token) { + logger('startup', 'WEBEX_BOT_TOKEN is required', 'error'); + process.exit(1); +} + +const framework = new Framework(frameworkConfig); + +// Framework debug logging is very chatty. Gate it on either WEBEX_FRAMEWORK_DEBUG=true +// or LOG_LEVEL=debug so production stays quiet by default but operators can still +// turn it on without code changes. +const FRAMEWORK_DEBUG = + (process.env.WEBEX_FRAMEWORK_DEBUG || '').toLowerCase() === 'true' || + (process.env.LOG_LEVEL || '').toLowerCase() === 'debug'; +framework.debug(FRAMEWORK_DEBUG); + +// `framework.start()` returns a `when`-style promise. Previously this was +// fire-and-forget — a token failure or transient Webex outage at startup would +// throw an unhandledRejection (now caught by the process-level handler) but +// without a clear "framework failed to start" log to anchor the diagnosis. +Promise.resolve(framework.start()).catch((err) => { + logger('framework', `Failed to start: ${err.message}`, 'error'); + // Re-throw so unhandledRejection -> shutdown() converges on the same exit + // path as any other unrecoverable startup error. + setImmediate(() => { throw err; }); +}); + +// ────────────────────────────────────────────── +// Adaptive Card submit handler +// ────────────────────────────────────────────── +// Single dispatch point for all attachmentAction events. We classify by the +// `action` input set and route to the right subsystem. Previously there were +// two separate framework.on('attachmentAction', …) listeners — one for +// offboard cards and one for DECT provisioning — with overlapping early-return +// logic that produced misleading "Offboard cancelled" messages for unrelated +// cards. One handler with explicit routing is easier to reason about. + +const DECT_ACTIONS = new Set([ + 'add-bases', + 'add-handset', + 'remove-bases', + 'remove-handsets', + 'refresh', + 'confirm-remove-bases', + 'confirm-remove-handsets', + 'cancel-remove', +]); +const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']); +const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']); + +// Best-effort delete of the adaptive-card message that fired this action. +// Removing the card prevents users from clicking Confirm/Cancel a second time +// (which would otherwise just be silently no-op'd by the pending-card map's +// .delete() one-shot guard, but would still look interactive in the UI). +// +// bot.censor() (see node_modules/webex-node-bot-framework/lib/bot.js:1368) +// requires the bot be the author of the message — which we always are for +// these cards — so this should never fail in practice. We still wrap in +// try/catch so a transient Webex API hiccup doesn't block the actual +// confirm/cancel operation that the user clicked. +async function censorActionCard(bot, trigger, scope) { + const messageId = trigger.attachmentAction?.messageId; + if (!messageId) return; + try { + await bot.censor(messageId); + logger(scope, `Removed card message ${messageId} after action`, 'debug'); + } catch (err) { + logger( + scope, + `Could not remove card message ${messageId}: ${err.message}`, + 'warn', + ); + } +} + +framework.on('attachmentAction', async (bot, trigger) => { + const action = trigger.attachmentAction; + if (!action || !action.inputs) { + logger('action', 'Received attachmentAction without inputs', 'debug'); + return; + } + + const actionType = action.inputs.action; + if (!actionType) { + logger('action', 'attachmentAction inputs missing `action` field', 'debug'); + return; + } + + // ── DECT provisioning ── + if (DECT_ACTIONS.has(actionType)) { + try { + await handleDectProvisionAction(bot, trigger); + } catch (err) { + logger('phone:provision', `Dect action error: ${err.message}`, 'error'); + } + return; + } + + // ── Offboard confirm / cancel ── + if (OFFBOARD_ACTIONS.has(actionType)) { + const { cardId } = action.inputs; + const roomId = trigger.roomId || action.roomId; + + if (!cardId) { + logger('offboard:action', `Missing cardId on ${actionType} — ignoring`); + return; + } + if (!pendingOffboards.has(cardId)) { + logger('offboard:action', `Card ${cardId} is expired or unknown`); + return; + } + + const offboardData = pendingOffboards.get(cardId); + pendingOffboards.delete(cardId); // one-shot + logger('offboard:action', `Received ${actionType} for card ${cardId} (user: ${offboardData.email})`); + + // Remove the card so a second click can't re-fire (defence in depth + // alongside the pendingOffboards one-shot guard above). + await censorActionCard(bot, trigger, 'offboard:action'); + + // The clicker on an adaptive card is the requester for audit purposes — + // not necessarily the same person who originally posted /offboarduser. + // See utils/requester.js for why we read trigger.person, not the + // never-populated trigger.personEmail field. + const requester = extractRequester(trigger); + + try { + if (actionType === 'confirm_offboard') { + await applyOffboardConfirmation(bot, offboardData, roomId, requester); + } else { + await cancelOffboardCard(bot, offboardData, roomId, requester); + } + } catch (err) { + logger( + 'offboard:action', + `Error processing ${actionType} for ${offboardData.email}: ${err.message}`, + 'error', + ); + await bot.say('markdown', `⚠️ Error during offboard processing: ${err.message}`); + } + return; + } + + // ── Webex host license assign confirm / cancel ── + if (HOST_ASSIGN_ACTIONS.has(actionType)) { + const { cardId } = action.inputs; + const roomId = trigger.roomId || action.roomId; + + if (!cardId) { + logger('webexhost:action', `Missing cardId on ${actionType} — ignoring`); + return; + } + if (!pendingHostAssigns.has(cardId)) { + logger('webexhost:action', `Card ${cardId} is expired or unknown`); + return; + } + + const hostData = pendingHostAssigns.get(cardId); + pendingHostAssigns.delete(cardId); // one-shot + logger( + 'webexhost:action', + `Received ${actionType} for card ${cardId} (user: ${hostData.email}, license: ${hostData.licenseName})`, + ); + + // Remove the card so a second click can't re-fire (defence in depth + // alongside the pendingHostAssigns one-shot guard above). + await censorActionCard(bot, trigger, 'webexhost:action'); + + const requester = extractRequester(trigger); + + try { + if (actionType === 'confirm_host_assign') { + await applyHostAssignConfirmation(bot, hostData, roomId, requester); + } else { + await cancelHostAssignCard(bot, hostData, roomId, requester); + } + } catch (err) { + logger( + 'webexhost:action', + `Error processing ${actionType} for ${hostData.email}: ${err.message}`, + 'error', + ); + // bot is already room-scoped — do not pass roomId as a 3rd positional arg. + await bot.say('markdown', `⚠️ Error during host assignment: ${err.message}`); + } + return; + } + + logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug'); +}); + +framework.on("initialized", () => { + logger('framework', 'Webex Framework is all fired up! [Press CTRL-C to quit]'); +}); + +// ────────────────────────────────────────────── +// Command Handler (registry-driven) +// ────────────────────────────────────────────── +// Strips bot mentions, picks off the first token as the command name, and +// dispatches through commands/registry.js. Unknown commands fall through to +// handleUnknown (which replies with the help text). + +const BOT_MENTION_PATTERNS = [ + /^CollabSupport\s+/i, + /^@CollabSupport\s+/i, + /^aeCollabSupport@webex\.bot\s+/i, +]; + +framework.hears(/.*/, async (bot, trigger) => { + let rawText = trigger.text?.trim() || ''; + for (const pattern of BOT_MENTION_PATTERNS) { + rawText = rawText.replace(pattern, '').trim(); + } + logger('command-parser', `Cleaned input: ${rawText}`, 'debug'); + + const parts = rawText.split(/\s+/); + const name = parts[0]?.toLowerCase().replace(/^\//, '') || ''; + const args = parts.slice(1); + trigger.args = args; + logger('command-parser', `Detected command: ${name} | Args: ${args.join(', ') || 'none'}`, 'debug'); + + const cmd = getCommand(name); + + try { + if (cmd) { + await cmd.handler(bot, trigger); + } else { + await handleUnknown(bot, trigger); + } + } catch (err) { + logger('command', `Error executing ${name}: ${err.message}`, 'error'); + await bot.say('markdown', `Error executing command: ${err.message}`); + } +}, null, 1); + +app.listen(PORT, () => { + logger('server', `🚀 Express server running on port ${PORT}`); + console.log(`🚀 HTTP API server listening on http://localhost:${PORT}`); + + const tokenConfigured = !!process.env.HTTP_API_TOKEN; + const lockEverything = requireAuthForAllCommands(); + if (tokenConfigured) { + logger( + 'server', + lockEverything + ? 'HTTP API auth: ALL endpoints require HTTP_API_TOKEN (HTTP_API_REQUIRE_AUTH=true)' + : `HTTP API auth: token required for mutating commands (${[...MUTATING_COMMAND_KEYS].sort().join(', ')})` + ); + } else { + logger( + 'server', + 'HTTP_API_TOKEN is not set — mutating /:command endpoints will refuse all requests with 503. ' + + 'Set HTTP_API_TOKEN to enable them.', + 'warn' + ); + } +}); + +// ────────────────────────────────────────────── +// Cron Jobs +// ────────────────────────────────────────────── +import cron from 'node-cron'; +import { pollNewTickets } from './services/jiraPollerService.js'; + +cron.schedule('15 0,8,16 * * *', async () => { + logger('cron', 'Starting scheduled cache refresh'); + try { + await Promise.allSettled([ + refreshMerakiNetworksCache(), + refreshAtlasDevicesCache(), + ]); + logger('cron', 'Cache refresh completed'); + } catch (err) { + logger('cron', `Cache refresh error: ${err.message}`, 'error'); + } +}); + +// Hourly Jira poller — see services/jiraPollerService.js. Gated on +// JIRA_POLLER_ROOM_ID so an unconfigured bot doesn't schedule dead work +// (the poller needs a room to post its "N new tickets" summary to). +// The single source of truth for "already seen" is a Jira label, not +// local state, so the cron is safe to fire even across bot restarts / +// concurrent instances. +if (process.env.JIRA_POLLER_ROOM_ID) { + cron.schedule('0 * * * *', async () => { + logger('cron', 'Starting hourly Jira poll'); + try { + await pollNewTickets(); + } catch (err) { + logger('jira:poller', `Unhandled poll error: ${err.message}`, 'error'); + } + }); + logger('startup', `Jira poller scheduled — hourly on the top of the hour → room ${process.env.JIRA_POLLER_ROOM_ID.slice(0, 8)}...`); +} else { + logger('startup', 'Jira poller disabled — set JIRA_POLLER_ROOM_ID to enable', 'warn'); +} + +// Initial warm-up +(async () => { + logger('startup', 'Warming up caches...'); + await Promise.allSettled([ + refreshMerakiNetworksCache(), + refreshAtlasDevicesCache(), + ]); + logger('startup', 'Initial cache warm-up done'); + + // One-shot prime pass: flip JIRA_POLLER_PRIME_ON_START=true for a + // single deploy to bulk-label everything currently in the queue + // WITHOUT enriching or notifying (avoids a giant day-one spam). Flip + // back to false after the run. Requires the poller to be enabled. + if (process.env.JIRA_POLLER_ROOM_ID && process.env.JIRA_POLLER_PRIME_ON_START === 'true') { + logger('startup', 'JIRA_POLLER_PRIME_ON_START=true — running one-shot prime pass'); + try { + const result = await pollNewTickets({ prime: true }); + logger('startup', `Prime pass complete: primed=${result.primed ?? 0}, skipped=${result.skipped}. Remove JIRA_POLLER_PRIME_ON_START from .env before next restart.`); + } catch (err) { + logger('startup', `Prime pass failed: ${err.message}`, 'error'); + } + } +})(); + +// ────────────────────────────────────────────── +// Graceful Shutdown & Process-level Error Handling +// ────────────────────────────────────────────── + +// Single graceful-stop path so SIGINT/SIGTERM/uncaughtException all converge +// to the same behavior: stop the Webex framework, then exit. We intentionally +// exit after uncaughtException/unhandledRejection — Node's docs warn that +// continuing after an uncaught exception leaves the process in an undefined +// state, and Docker's `restart: unless-stopped` will recover us cleanly. + +let isShuttingDown = false; + +async function shutdown(reason, exitCode) { + if (isShuttingDown) return; + isShuttingDown = true; + logger('shutdown', `Shutting down — ${reason}`); + try { + await framework.stop(); + logger('shutdown', 'Webex Framework stopped'); + } catch (err) { + logger('shutdown', `Error during framework.stop(): ${err.message}`, 'error'); + } finally { + process.exit(exitCode); + } +} + +process.on('SIGINT', () => shutdown('SIGINT', 0)); +process.on('SIGTERM', () => shutdown('SIGTERM', 0)); + +// nodemon's default restart signal is SIGUSR2. Without an explicit handler, +// Node terminates immediately and framework.stop() never runs — leaving the +// WDM device registration alive on Cisco's side. Each leaked registration +// counts toward the per-bot 100-device cap and the bot eventually 403s on +// startup ("Forbidden: User has excessive device registrations"). Handling +// SIGUSR2 here gives framework.stop() a chance to call +// webex.internal.device.unregister() before exit. We also ship a +// nodemon.json that prefers SIGTERM, but this remains a defensive backstop. +process.on('SIGUSR2', () => shutdown('SIGUSR2', 0)); + +process.on('uncaughtException', (err) => { + logger('uncaught', `Uncaught exception: ${err.message}\n${err.stack}`, 'error'); + // Exit fast — Docker will restart us. Continuing risks operating on torn-down state. + shutdown('uncaughtException', 1); +}); + +process.on('unhandledRejection', (reason) => { + const msg = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason); + logger('uncaught', `Unhandled promise rejection: ${msg}`, 'error'); + shutdown('unhandledRejection', 1); +}); \ No newline at end of file diff --git a/integrations/atlas/client.js b/integrations/atlas/client.js new file mode 100644 index 0000000..9814b28 --- /dev/null +++ b/integrations/atlas/client.js @@ -0,0 +1,49 @@ +// src/integrations/atlas/client.js + +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +export const atlasAxios = axios.create({ + baseURL: 'https://hub.xyte.io/core/v1', + timeout: 12000, + headers: { + 'Content-Type': 'application/json', + 'Authorization': process.env.ATLAS_AUTH_KEY, // assuming this is the full Bearer token or API key + }, +}); + +// Optional: request logging (remove or conditional in production) +atlasAxios.interceptors.request.use(cfg => { + logger('atlas:request', `${cfg.method.toUpperCase()} ${cfg.url}`, 'debug'); + return cfg; +}); + +atlasAxios.interceptors.response.use( + res => res, + err => { + const msg = err.response + ? `${err.response.status} - ${JSON.stringify(err.response.data?.message || err.response.data)}` + : err.message; + logger('atlas:error', msg); + return Promise.reject(err); + } +); + +/** + * Simple GET wrapper with better error context + */ +export async function atlasGet(endpoint, params = {}) { + try { + const res = await atlasAxios.get(endpoint, { params }); + return res.data; + } catch (err) { + const ctx = err.response?.data?.message || err.message; + throw new Error(`Atlas GET ${endpoint} failed: ${ctx}`); + } +} + +export default atlasAxios; + +// NOTE: Device list caching + find/getForStore live ONLY in ./devices.js +// (uses atlasGet + full pagination). Old single-page duplicates removed to prevent drift. + diff --git a/integrations/atlas/devices.js b/integrations/atlas/devices.js new file mode 100644 index 0000000..9f6c78f --- /dev/null +++ b/integrations/atlas/devices.js @@ -0,0 +1,145 @@ +// src/integrations/atlas/devices.js + +import { atlasGet } from './client.js'; +import { logger } from '../../utils/logger.js'; + +// In-memory cache (→ replace with node-cache/redis later if scale needed) +let cachedDeviceList = []; // full list from /organization/devices +let lastCacheTime = 0; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour – adjust based on how often devices change + +/** + * Refresh full organization device list (called by cron) + * Handles pagination (API defaults to 100 items/page, max per_page=100; uses ?page=N and next_page in body) + */ +export async function refreshAtlasDevicesCache() { + const start = Date.now(); + logger('atlas:devices', 'Refreshing full device cache', 'debug'); + + try { + let allItems = []; + let page = 1; + let hasMore = true; + const PAGE_SIZE = 100; + + while (hasMore) { + const data = await atlasGet('/organization/devices', { page, per_page: PAGE_SIZE }); + const items = Array.isArray(data.items) ? data.items : []; + allItems = allItems.concat(items); + + const nextPage = data.next_page; + hasMore = !!nextPage && items.length > 0; + + logger('atlas:devices', `Page ${page}: ${items.length} items (running total ${allItems.length}) next_page=${nextPage}`, 'debug'); + + if (items.length < PAGE_SIZE) { + hasMore = false; + } + if (hasMore) { + page = Number(nextPage) || (page + 1); + await new Promise(r => setTimeout(r, 80)); // be nice between pages + } + if (page > 100) { // hard safety + logger('atlas:devices', 'Safety stop: exceeded 100 pages'); + break; + } + } + + cachedDeviceList = allItems; + lastCacheTime = Date.now(); + + logger('atlas:devices', `Cached ${cachedDeviceList.length} devices (${Date.now() - start} ms)`, 'debug'); + } catch (err) { + logger('atlas:devices', `Cache refresh failed: ${err.message}`); + // Keep old cache if possible + } +} + +/** + * Get cached device list (auto-refresh if stale/empty) + */ +export async function getAtlasDeviceList(forceRefresh = false) { + const now = Date.now(); + if (forceRefresh || !cachedDeviceList.length || (now - lastCacheTime > CACHE_TTL_MS)) { + await refreshAtlasDevicesCache(); + } + return cachedDeviceList; +} + +/** + * Find devices matching a store number (name contains 6-digit padded storeNum, e.g. "002477" in "US002477AMP") + * @param {string|number} storeNumber e.g. "2477", 305 or "000305" + * @returns {Promise} matching device summaries (name, id, status, etc.) + */ +export async function findAtlasDevicesForStore(storeNumber) { + // Always use 6-digit zero-padded form for name matching in Atlas (e.g. 2477 → 002477, 305 → 000305). + // Raw/short numbers like "305" can substring-match unrelated devices (e.g. "00305x"), causing + // multiple/incorrect Atlas AMP results. Names follow US00NNNNAMP pattern. + const padded = String(storeNumber).trim().padStart(6, '0'); + logger('atlas:devices', `Looking up Atlas devices for store ${storeNumber} (padded ${padded})`, 'debug'); + + const devices = await getAtlasDeviceList(); + + const matches = devices.filter(dev => { + const name = String(dev.name || '').toUpperCase(); + return name.includes(padded); + }); + + logger('atlas:findDevices', + `Searched for store ${padded} → Found ${matches.length} Atlas AMP device(s)`, 'debug'); + + if (matches.length > 0) { + logger('atlas:findDevices', `Matched: ${matches.map(m => m.name).join(', ')}`, 'debug'); + } + + return matches; +} + +/** + * Get detailed info for a single Atlas device by its ID + * @param {string} deviceId + * @returns {Promise} + */ +export async function getAtlasDeviceDetail(deviceId) { + if (!deviceId) return null; + + const start = Date.now(); + logger('atlas:detail', `Fetching detail for device ${deviceId}`, 'debug'); + + try { + const data = await atlasGet(`/organization/devices/${deviceId}`); + logger('atlas:detail', `Detail fetched (${Date.now() - start} ms)`, 'debug'); + return data; + } catch (err) { + logger('atlas:detail', `Failed for ${deviceId}: ${err.message}`); + return null; + } +} + +/** + * Convenience: Get detailed device(s) for a store (find → fetch detail) + * Returns array (usually 0–1 items in practice) + */ +export async function getAtlasDeviceForStore(storeNumber) { + const candidates = await findAtlasDevicesForStore(storeNumber); + + if (candidates.length === 0) { + return []; + } + + // Fetch details for all matches (parallel) + const details = await Promise.allSettled( + candidates.map(c => getAtlasDeviceDetail(c.id)) + ); + + const successful = details + .filter(r => r.status === 'fulfilled') + .map(r => r.value) + .filter(Boolean); + + if (successful.length === 0) { + logger('atlas:getForStore', `No successful detail fetches for store ${storeNumber}`); + } + + return successful; +} \ No newline at end of file diff --git a/integrations/atlas/index.js b/integrations/atlas/index.js new file mode 100644 index 0000000..cad3770 --- /dev/null +++ b/integrations/atlas/index.js @@ -0,0 +1,2 @@ +export * from './client.js'; +export * from './devices.js'; \ No newline at end of file diff --git a/integrations/digicert/DigiCertClient.js b/integrations/digicert/DigiCertClient.js new file mode 100644 index 0000000..d7568ee --- /dev/null +++ b/integrations/digicert/DigiCertClient.js @@ -0,0 +1,71 @@ +// src/integrations/digicert/DigiCertClient.js +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +class DigiCertClient { + static #instance = null; + + constructor() { + if (DigiCertClient.#instance) return DigiCertClient.#instance; + + const apiKey = process.env.DIGICERT_API_KEY; + const baseURL = process.env.DIGICERT_BASE_URL || 'https://one.digicert.com'; + + if (!apiKey) { + logger('digicert:client', 'DIGICERT_API_KEY is missing from environment', 'error'); + throw new Error('Missing DIGICERT_API_KEY'); + } + + this.client = axios.create({ + baseURL, + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + timeout: 60000 + }); + + DigiCertClient.#instance = this; + logger('digicert:client', 'DigiCertClient initialized'); + } + + async enrollCertificate(csrBase64, commonName, deviceIp) { + const seatEmail = process.env.DIGICERT_SEAT_EMAIL; + if (!seatEmail) { + // Previously this silently fell back to a hardcoded personal address, + // which both pinned every cert to one person's email and leaked the + // identity through the source. Fail loud so misconfig is obvious. + throw new Error('DIGICERT_SEAT_EMAIL is not set in environment'); + } + + const payload = { + profile: { id: process.env.DIGICERT_PROFILE_ID }, + csr: csrBase64, + seat: { + seat_id: `${commonName}-${Date.now()}`, + seat_email: seatEmail, + }, + attributes: { + subject: { common_name: commonName }, + extensions: { + san: { + dns_names: [commonName], + ip_addresses: [deviceIp] + } + } + } + }; + + const response = await this.client.post('/mpki/api/v1/certificate', payload); + return response.data; + } + + async pickupCertificate(requestId) { + const payload = { profile: { id: process.env.DIGICERT_PROFILE_ID } }; + const response = await this.client.post(`/mpki/api/v1/certificate-pickup/${requestId}`, payload); + return response.data; + } +} + +export default new DigiCertClient(); \ No newline at end of file diff --git a/integrations/jira/JiraClient.js b/integrations/jira/JiraClient.js new file mode 100644 index 0000000..1f7dabe --- /dev/null +++ b/integrations/jira/JiraClient.js @@ -0,0 +1,248 @@ +// src/integrations/jira/JiraClient.js +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +// Shared error logger for write endpoints. A stock axios error like +// "Request failed with status code 401" tells you nothing about WHY +// Jira rejected the call — the actionable detail (missing scope, wrong +// permission, wrong shape) lives in the response body and, for 401s, +// the WWW-Authenticate header. Log all of it. Falls back gracefully +// when Jira returns an HTML error page (empty JSON body). +function logJiraWriteError(op, key, err) { + const status = err.response?.status || 'unknown'; + const data = err.response?.data; + // Jira's structured errors have `errorMessages[]` and/or + // `errors{}`. Auth failures at the api.atlassian.com gateway often + // return `{ code, message }` instead. Show whatever's present. + let detail; + if (typeof data === 'string') { + detail = data.slice(0, 300); + } else if (data && typeof data === 'object') { + const parts = []; + if (Array.isArray(data.errorMessages) && data.errorMessages.length) { + parts.push(`errorMessages=${data.errorMessages.join('; ')}`); + } + if (data.errors && Object.keys(data.errors).length) { + parts.push(`errors=${JSON.stringify(data.errors)}`); + } + if (data.message) parts.push(`message=${data.message}`); + if (data.code) parts.push(`code=${data.code}`); + detail = parts.length ? parts.join(' | ') : JSON.stringify(data).slice(0, 300); + } else { + detail = err.message; + } + // WWW-Authenticate carries Bearer/OAuth error hints for connected + // apps — e.g. `error="insufficient_scope", scope="write:comment:jira"`. + const wwwAuth = err.response?.headers?.['www-authenticate']; + const wwwPart = wwwAuth ? ` | WWW-Authenticate=${wwwAuth}` : ''; + logger( + 'jira:client', + `Failed to ${op} on ${key} [${status}] - ${detail}${wwwPart}`, + 'error', + ); +} + +class JiraClient { + static #instance = null; + + // Field-name -> field-id cache populated on first getFieldIdByName() call. + // null means "not yet fetched"; a Map means we've called /field once and + // memoized the whole schema for this process lifetime. Custom-field ids + // don't change on a running Jira site, so no TTL is needed. + #fieldIdCache = null; + + constructor() { + if (JiraClient.#instance) return JiraClient.#instance; + + const cloudId = process.env.JIRA_CLOUD_ID; + const baseURL = process.env.JIRA_BASE_URL; + const email = process.env.JIRA_EMAIL; + const token = process.env.JIRA_API_TOKEN; + + if (!email || !token) { + logger('jira:client', 'Missing JIRA_EMAIL or JIRA_API_TOKEN – Jira features will fail', 'error'); + throw new Error('Missing Jira configuration'); + } + + let effectiveBase; + if (cloudId) { + // New service account / Atlassian Cloud API gateway form (required for some accounts) + // e.g. https://api.atlassian.com/ex/jira/f52e9ac9-59a4-4465-8c04-6a05e368107c + effectiveBase = `https://api.atlassian.com/ex/jira/${cloudId}`; + logger('jira:client', `Using Jira Cloud ID base (service account): ${cloudId}`); + } else if (baseURL) { + effectiveBase = baseURL; + } else { + logger('jira:client', 'Missing JIRA_BASE_URL or JIRA_CLOUD_ID – Jira features will fail', 'error'); + throw new Error('Missing Jira configuration'); + } + + // Basic Auth (email:apiToken). Works for both classic site URLs and the api.atlassian.com/ex/jira/ + // gateway used by service accounts / connected apps. + const auth = Buffer.from(`${email}:${token}`).toString('base64'); + + this.axios = axios.create({ + baseURL: `${effectiveBase}/rest/api/3`, + timeout: 15000, + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + }); + + JiraClient.#instance = this; + logger('jira:client', 'JiraClient initialized successfully'); + } + + /** + * Search Jira using JQL + */ + async search(jql, fields = 'key,summary,status,resolution,assignee,created,resolved,components', maxResults = null) { + const limit = maxResults || parseInt(process.env.JIRA_MAX_RESULTS) || 20; + + try { + const payload = { + jql: jql.trim(), + fields: fields.split(',').map(f => f.trim()), + maxResults: limit, + expand: "comment" + }; + + // Use /search/jql as shown in your working Postman call + const response = await this.axios.post('/search/jql', payload); + + const issueCount = response.data.issues?.length || 0; + logger('jira:client', `Search successful - ${issueCount} issues returned`, 'debug'); + + return response.data; + + } catch (err) { + const status = err.response?.status || 'unknown'; + const errorMsg = err.response?.data?.errorMessages?.join(', ') + || err.response?.data?.message + || err.message; + + logger('jira:client', `Search failed [${status}] - ${errorMsg}`, 'error'); + throw err; + } + } + + /** + * Get full details for a single ticket + */ + async getTicket(key) { + try { + const response = await this.axios.get(`/issue/${key}`, { + params: { + expand: 'comment,renderedFields' + } + }); + + const commentCount = response.data.fields?.comment?.comments?.length || 0; + logger('jira:client', `Fetched ticket ${key} (${commentCount} comments)`, 'debug'); + + return response.data; + + } catch (err) { + const status = err.response?.status; + logger('jira:client', `Failed to fetch ticket ${key} [${status}]`, 'error'); + throw err; + } + } + + /** + * Post a comment on an issue. + * + * Jira Cloud REST v3 requires the `body` to be an ADF (Atlassian + * Document Format) document — a JSON tree, not markdown or wiki + * markup. Callers are responsible for constructing valid ADF; see + * services/jiraPollerService.js:buildAdfComment for the pattern we + * use for status snapshots. + * + * @param {string} key Issue key, e.g. 'SUPPORT-1234' + * @param {object} adfBody ADF document (root object with type:'doc') + * @returns {Promise} The created comment payload from Jira. + */ + async addComment(key, adfBody) { + try { + const response = await this.axios.post(`/issue/${key}/comment`, { + body: adfBody, + }); + logger('jira:client', `Added comment on ${key} (id=${response.data?.id || 'unknown'})`, 'debug'); + return response.data; + } catch (err) { + logJiraWriteError('addComment', key, err); + throw err; + } + } + + /** + * Append a label to an issue. Uses the `update` semantics of PUT + * /issue/{key} which is safe for concurrent labelers — Jira merges + * the add into the existing label set rather than replacing it. If + * the label is already present, Jira treats the PUT as a no-op. + * + * @param {string} key Issue key. + * @param {string} label Label to add (no spaces; Jira rejects + * labels containing whitespace). + */ + async addLabel(key, label) { + try { + await this.axios.put(`/issue/${key}`, { + update: { labels: [{ add: label }] }, + }); + logger('jira:client', `Added label '${label}' on ${key}`, 'debug'); + } catch (err) { + logJiraWriteError('addLabel', key, err); + throw err; + } + } + + /** + * Resolve a custom-field display name (e.g. 'Store Number') to its + * numeric id (e.g. 'customfield_10042'). Fetches the org-wide field + * schema once via GET /field on first call, then serves from an + * in-process cache — the schema doesn't drift on a live Jira site, + * so no TTL is warranted. + * + * Returns null if no field matches (case-insensitive) so callers can + * fall back to an env-var override without an exception. + * + * @param {string} name Display name to search for. + * @returns {Promise} Field id (e.g. 'customfield_10042') or null. + */ + async getFieldIdByName(name) { + if (!name) return null; + if (!this.#fieldIdCache) { + try { + const response = await this.axios.get('/field'); + const fields = Array.isArray(response.data) ? response.data : []; + this.#fieldIdCache = new Map(); + for (const f of fields) { + if (f?.name && f?.id) { + // Lowercase the key so lookups are case-insensitive. + // Later duplicates overwrite earlier ones, which is + // typically fine — but we log if we spot a collision + // so operators know two fields share a display name. + const key = f.name.toLowerCase(); + if (this.#fieldIdCache.has(key)) { + logger('jira:client', `Field name collision on '${f.name}': keeping ${f.id}, previously ${this.#fieldIdCache.get(key)}`, 'warn'); + } + this.#fieldIdCache.set(key, f.id); + } + } + logger('jira:client', `Cached ${this.#fieldIdCache.size} Jira field name/id mappings`); + } catch (err) { + const status = err.response?.status || 'unknown'; + logger('jira:client', `Failed to fetch field schema [${status}] - ${err.message}`, 'error'); + // Leave cache null so a future call can retry rather than + // permanently caching an empty result on a transient failure. + throw err; + } + } + return this.#fieldIdCache.get(name.toLowerCase()) || null; + } +} + +// Export as singleton +export default new JiraClient(); \ No newline at end of file diff --git a/integrations/mdm/client.js b/integrations/mdm/client.js new file mode 100644 index 0000000..07de869 --- /dev/null +++ b/integrations/mdm/client.js @@ -0,0 +1,197 @@ +// src/integrations/mdm/client.js +// VMware Workspace ONE / AirWatch MDM integration +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +const MDM_BASE_URL = 'https://as1991.awmdm.com'; +const TOKEN_URL = 'https://na.uemauth.workspaceone.com/connect/token'; + +// Dedicated axios instance +export const mdmAxios = axios.create({ + baseURL: MDM_BASE_URL, + timeout: 12000, + headers: { + 'Accept': 'application/json', + }, +}); + +// ────────────────────────────────────────────── +// Get OAuth token (client_credentials flow) +// ────────────────────────────────────────────── +export async function getMDMToken() { + logger('mdm:token', 'Requesting new MDM OAuth token', 'debug'); + + try { + const response = await axios.post( + TOKEN_URL, + new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.WS1_CLIENT_ID, + client_secret: process.env.WS1_CLIENT_SECRET, + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + + const accessToken = response.data.access_token; + logger('mdm:token', `MDM token acquired successfully (length: ${accessToken.length})`, 'debug'); + return accessToken; + + } catch (err) { + const msg = err.response + ? `${err.response.status} – ${JSON.stringify(err.response.data)}` + : err.message; + + logger('mdm:token', `Failed to acquire MDM token: ${msg}`, 'error'); + throw new Error(`MDM token fetch failed: ${msg}`); + } +} + +// ────────────────────────────────────────────── +// Get MDM devices for a store +// ────────────────────────────────────────────── +export async function getMDMDevices2(storeNum) { + logger('mdm:device', `Fetching devices for store ${storeNum}`, 'debug'); + const storeNumPadded = String(storeNum).padStart(6, '0'); + + try { + const accessToken = await getMDMToken(); + logger('mdm:device', `Searching devices for user/store: ${storeNumPadded}`, 'debug'); + + const response = await axios.get('/api/mdm/devices/search', { + baseURL: MDM_BASE_URL, + params: { user: storeNumPadded }, + headers: { + Authorization: `Bearer ${accessToken}`, + 'aw-tenant-code': process.env.WS1_TENANT_CODE, + Accept: 'application/json' + } + }); + + let devices = response.data.Devices || []; + logger('mdm:device', `Raw devices returned from MDM: ${devices.length}`, 'debug'); + logger('mdm:client', `Fetched ${response.data.Devices?.length || 0} devices from MDM`); + const result = devices.map(d => ({ + friendlyName: d.DeviceFriendlyName || 'Unknown', + serialNumber: d.SerialNumber || '—', + lastSeen: d.LastSeen || d.LastSystemSampleTime || 'Unknown', + locationGroup: d.LocationGroupId?.Name || d.LocationGroupName || 'Unknown', + orgGroupId: parseInt(d.OrganizationalGroupID || d.orgGroupId || d.OrganizationalGroup || d.groupId || 0, 10) + })); + + logger('mdm:device', `Returning ${result.length} MDM devices for store ${storeNum}`, 'debug'); + return result; + + } catch (err) { + logger('mdm:device', `Error fetching devices for store ${storeNum}: ${err.message}`, 'error'); + if (err.response) { + logger('mdm:device', `MDM API error status: ${err.response.status}`, 'error'); + } + return []; + } +} + +export async function getMDMDevices(storeNum) { + logger('mdm:device', `Fetching devices for store ${storeNum}`, 'debug'); + const storeNumPadded = String(storeNum).padStart(6, '0'); + + try { + const accessToken = await getMDMToken(); + logger('mdm:device', `Searching devices for user/store: ${storeNumPadded}`, 'debug'); + + const response = await axios.get('/api/mdm/devices/search', { + baseURL: MDM_BASE_URL, + params: { user: storeNumPadded }, + headers: { + Authorization: `Bearer ${accessToken}`, + 'aw-tenant-code': process.env.WS1_TENANT_CODE, + Accept: 'application/json' + } + }); + + let devices = response.data.Devices || []; + logger('mdm:device', `Raw devices returned from MDM: ${devices.length}`, 'debug'); + + logger('mdm:device', `Returning ${devices.length} MDM devices for store ${storeNum}`, 'debug'); + return devices; + + } catch (err) { + logger('mdm:device', `Error fetching devices for store ${storeNum}: ${err.message}`, 'error'); + if (err.response) { + logger('mdm:device', `MDM API error status: ${err.response.status}`, 'error'); + } + return []; + } +} +/** + * Get ALL devices with NO filtering whatsoever + * No platform parameter, no client-side filtering. + */ +export async function getMDMDevicesByPlatform(platformFilter = null, maxDevices = 9999) { + logger('mdm:device', `Fetching ALL devices (broad search, no filters at all)`); + + let allDevices = []; + let page = 0; + const pageSize = 500; + + try { + while (allDevices.length < maxDevices) { + const accessToken = await getMDMToken(); + + const response = await axios.get('/api/mdm/devices/search', { + baseURL: 'https://as1991.awmdm.com', + params: { + page: page, + page_size: pageSize, + platform: platformFilter + // NO platform, NO group, NO other filters + }, + headers: { + Authorization: `Bearer ${accessToken}`, + 'aw-tenant-code': process.env.WS1_TENANT_CODE, + Accept: 'application/json' + } + }); + + const pageDevices = response.data.Devices || response.data.devices || response.data.results || []; + allDevices = allDevices.concat(pageDevices); + + logger('mdm:device', `Page ${page} returned ${pageDevices.length} devices (total so far: ${allDevices.length})`); + + if (pageDevices.length < pageSize) break; + + page++; + } + + logger('mdm:device', `Broad fetch complete – ${allDevices.length} total devices returned (NO filtering applied)`); + + return allDevices.slice(0, maxDevices); + + } catch (err) { + logger('mdm:device', `Broad fetch failed: ${err.message}`, 'error'); + return []; + } +} + +/** + * Returns ONLY Audio-Visual devices from MDM (VW, MSC, LED in name) + * Used specifically for the AV modal view + */ +export async function getAVMDMDevices(storeNum) { + logger('mdm:av', `Fetching AV devices (VW/MSC/LED) for store ${storeNum}`, 'debug'); + + const allDevices = await getMDMDevices(storeNum); + if (!allDevices || allDevices.length === 0) return []; + + const AV_PATTERN = /(VW|MSC|LED|AppleTV)/i; + + const avDevices = allDevices.filter(device => { + const name = (device.UserName || '').toUpperCase(); + return AV_PATTERN.test(name); + }); + + logger('mdm:av', `MDM AV filter: ${allDevices.length} total → ${avDevices.length} AV devices`, 'debug'); + return avDevices; +} +export default mdmAxios; diff --git a/integrations/mdmcorp/client.js b/integrations/mdmcorp/client.js new file mode 100644 index 0000000..ee2cf66 --- /dev/null +++ b/integrations/mdmcorp/client.js @@ -0,0 +1,120 @@ +// src/integrations/mdmcorp/client.js +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +let accessToken = null; +let tokenExpiresAt = 0; + +const mdmcorpAxios = axios.create({ + baseURL: process.env.CORP_WS1_API_BASE, + timeout: 15000, + headers: { + 'Accept': 'application/json', + }, +}); + +// Get OAuth token using client_credentials flow (for CORP MDM) +async function getMDMCorpToken() { + const now = Date.now(); + if (accessToken && now < tokenExpiresAt) { + return accessToken; + } + + logger('mdmcorp:auth', 'Requesting new CORP MDM token'); + + try { + const response = await axios.post( + 'https://na.uemauth.workspaceone.com/connect/token', // Workspace ONE auth endpoint + new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.CORP_WS1_CLIENT_ID, + client_secret: process.env.CORP_WS1_CLIENT_SECRET, + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + + accessToken = response.data.access_token; + tokenExpiresAt = now + (response.data.expires_in * 1000) - 300000; // 5 min buffer + + logger('mdmcorp:auth', 'CORP MDM token acquired successfully'); + return accessToken; + } catch (err) { + logger('mdmcorp:auth', `Token request failed: ${err.message}`, 'error'); + throw new Error('Failed to obtain CORP MDM token'); + } +} + +// Add token to every request +mdmcorpAxios.interceptors.request.use(async (cfg) => { + const token = await getMDMCorpToken(); + cfg.headers.Authorization = `Bearer ${token}`; + cfg.headers['aw-tenant-code'] = process.env.CORP_WS1_TENANT_CODE; + logger('mdmcorp:request', `${cfg.method.toUpperCase()} ${cfg.url}`); + return cfg; +}); + +mdmcorpAxios.interceptors.response.use( + res => res, + err => { + const msg = err.response + ? `${err.response.status} - ${JSON.stringify(err.response.data?.message || err.response.data)}` + : err.message; + logger('mdmcorp:error', msg, 'error'); + return Promise.reject(err); + } +); + +/** + * Search devices by email in the CORP MDM instance + * Uses only the username part (before @) as required by this tenant + */ +export async function findDevicesByEmail(fullEmail) { + if (!fullEmail) return []; + + // Extract username only (e.g. "bollandd" from "bollandd@ae.com") + const username = fullEmail.split('@')[0].trim(); + if (!username) return []; + + logger('mdmcorp:device', `Searching CORP MDM for username: ${username} (from ${fullEmail})`); + + try { + const response = await mdmcorpAxios.get('/api/mdm/devices/search', { + params: { user: username } + }); + + const devices = response.data?.Devices || response.data || []; + + logger('mdmcorp:device', `Found ${devices.length} devices for username ${username}`); + + // Optional: log first device for debugging + if (devices.length > 0) { + logger('mdmcorp:device', `First device: ${JSON.stringify(devices[0], null, 2)}`, 'debug'); + } + + return devices; + + } catch (err) { + logger('mdmcorp:device', `Search failed for ${username}: ${err.response?.status || err.message}`, 'error'); + return []; + } +} + +/** + * Perform Enterprise Wipe on a device + */ +export async function enterpriseWipe(deviceId) { + if (!deviceId) throw new Error('Device ID is required'); + + try { + const response = await mdmcorpAxios.post(`/api/mdm/devices/${deviceId}/commands/enterpriseWipe`); + logger('mdmcorp:wipe', `Enterprise wipe initiated for device ${deviceId}`); + return response.data; + } catch (err) { + logger('mdmcorp:wipe', `Failed to wipe device ${deviceId}: ${err.message}`, 'error'); + throw err; + } +} + +export default mdmcorpAxios; diff --git a/integrations/meraki/client.js b/integrations/meraki/client.js new file mode 100644 index 0000000..e78bfdd --- /dev/null +++ b/integrations/meraki/client.js @@ -0,0 +1,103 @@ +// src/integrations/meraki/client.js +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +export const merakiAxios = axios.create({ + baseURL: 'https://api.meraki.com/api/v1', + timeout: 30000, + headers: { + 'X-Cisco-Meraki-API-Key': process.env.MERAKI_API_KEY, + 'Content-Type': 'application/json', + }, +}); + +// Request logging +merakiAxios.interceptors.request.use(cfg => { + logger('meraki:request', `${cfg.method.toUpperCase()} ${cfg.url}`, 'debug'); + return cfg; +}); + +// Response error logging + basic 429 info +merakiAxios.interceptors.response.use( + res => res, + err => { + const status = err.response?.status; + const errors = err.response?.data?.errors || err.response?.data; + const retryAfter = err.response?.headers?.['retry-after']; + + const msg = status + ? `${status} - ${JSON.stringify(errors)} ${retryAfter ? `(Retry-After: ${retryAfter}s)` : ''}` + : err.message; + + logger('meraki:error', msg, status === 429 ? 'warn' : 'error'); + return Promise.reject(err); + } +); + +/** + * Robust fetchAllPages with automatic 429 retry + exponential backoff + */ +export async function fetchAllPages(baseUrl, maxRetries = 6) { + let allResults = []; + let nextUrl = baseUrl.includes('?') + ? `${baseUrl}&perPage=5000` + : `${baseUrl}?perPage=5000`; + + let attempt = 0; + + while (nextUrl) { + try { + logger('meraki:pagination', `Fetching from ${nextUrl}`, 'debug'); + + const response = await merakiAxios.get(nextUrl); + const pageData = response.data || []; + + allResults = allResults.concat(pageData); + + // Handle Link header for pagination + const linkHeader = response.headers.link; + if (linkHeader) { + const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel=["']?next["']?/i); + nextUrl = nextMatch ? nextMatch[1] : null; + } else { + nextUrl = null; + } + + logger('meraki:pagination', `Fetched ${pageData.length} items (total now ${allResults.length})`, 'debug'); + + // Small delay between pages to be kind to the API + if (nextUrl) await new Promise(r => setTimeout(r, 120)); + + } catch (err) { + if (err.response?.status === 429 && attempt < maxRetries) { + const retryAfter = parseInt(err.response.headers['retry-after']) || Math.pow(2, attempt) * 2; // exponential backoff fallback + + logger('meraki:rate-limit', + `429 Rate limit hit on ${nextUrl} — waiting ${retryAfter}s (attempt ${attempt + 1}/${maxRetries})`, + 'warn'); + + await new Promise(r => setTimeout(r, retryAfter * 1000)); + attempt++; + continue; // retry the same URL + } + + // Non-429 error or max retries reached + logger('meraki:error', `Failed after ${attempt} retries: ${err.message}`, 'error'); + throw err; + } + } + // logger('meraki:debug', JSON.stringify(allResults[0], null, 2)); // intentionally disabled + logger('meraki:pagination', `Pagination complete – ${allResults.length} total items`, 'debug'); + return allResults; +} + +// Export a reusable client instance +let merakiClientInstance = null; + +export async function getMerakiClient() { + if (!merakiClientInstance) { + // Just return the axios instance we already configured + merakiClientInstance = merakiAxios; + } + return merakiClientInstance; +} \ No newline at end of file diff --git a/integrations/meraki/clients.js b/integrations/meraki/clients.js new file mode 100644 index 0000000..9d32402 --- /dev/null +++ b/integrations/meraki/clients.js @@ -0,0 +1,323 @@ +// src/integrations/meraki/clients.js +import { fetchAllPages } from './client.js'; +import { logger } from '../../utils/logger.js'; +import { findMerakiNetwork } from './networks.js'; +import { merakiAxios } from './client.js'; +import { findBestMerakiClientMatch } from '../../services/enrichment/merakiMatcher.js'; +import { attachMerakiClientWithPorts } from '../../services/enrichment/merakiEnrichment.js'; +/** + * Get all clients from a network + */ +export async function getMerakiClients(networkId, timespanDays = 7) { + logger('meraki:clients', `Fetching clients for network ${networkId} (timespan: ${timespanDays} days)`, 'debug'); + + if (!networkId) { + logger('meraki:clients', 'No networkId provided', 'warn'); + return []; + } + + try { + const timespanSeconds = timespanDays * 24 * 60 * 60; + const url = `/networks/${networkId}/clients?perPage=5000×pan=${timespanSeconds}`; + + const clients = await fetchAllPages(url); + + logger('meraki:clients', `Fetched ${clients.length} clients from network ${networkId}`, 'debug'); + return clients; + + } catch (err) { + logger('meraki:clients', `Error fetching clients for network ${networkId}: ${err.message}`, 'error'); + return []; + } +} + +/** + * Get clients for a specific store — now returns { clients, network } + */ +export async function getClientsForStore(storeNumber, timespanDays = 7) { + const network = await findMerakiNetwork(storeNumber); // ← full object + if (!network) { + logger('meraki:clients', `No network found for store ${storeNumber}`); + return { clients: [], network: null, networkId: null }; + } + + const clients = await getMerakiClients(network.id, timespanDays); + return { + clients, + network, // ← full network object with .url + networkId: network.id + }; +} + +/** + * Get detailed port status and configuration for a network + * Only queries MS switches (Meraki Switch devices) + */ +export async function getMerakiPorts(networkId) { + logger('meraki:ports', `Fetching port configurations for network ${networkId}`, 'debug'); + + if (!networkId) { + logger('meraki:ports', 'No networkId provided', 'warn'); + return []; + } + + try { + const devicesUrl = `/networks/${networkId}/devices`; + const devices = await fetchAllPages(devicesUrl); + + let allPorts = []; + + for (const device of devices) { + // Only MS switches support switch ports endpoint + if (!device.model || !device.model.startsWith('MS')) { + logger('meraki:ports', `Skipping non-switch device ${device.serial} (${device.model || 'unknown'})`, 'debug'); + continue; + } + + logger('meraki:ports', `Fetching ports for MS switch ${device.serial} (${device.model})`); + + try { + const portsUrl = `/devices/${device.serial}/switch/ports`; + const ports = await fetchAllPages(portsUrl); + + const enrichedPorts = ports.map(port => ({ + deviceSerial: device.serial, + deviceName: device.name || device.model, + model: device.model, + portId: port.portId, + portNumber: port.number, + enabled: port.enabled, + status: port.status || 'unknown', + poeEnabled: port.poeEnabled, + poePower: port.poePower || 0, + accessPolicy: port.accessPolicy, + stickyMac: port.stickyMac || false, + allowedMacs: port.allowedMacs || [], + voiceVlan: port.voiceVlan, + dataVlan: port.vlan, + portName: port.name || `Port ${port.number}`, + errors: port.errors || [], + packetErrors: { + rxErrors: port.rxErrors || 0, + txErrors: port.txErrors || 0, + collisions: port.collisions || 0 + }, + lastUpdated: port.lastUpdated || null + })); + + allPorts = allPorts.concat(enrichedPorts); + } catch (portErr) { + logger('meraki:ports', `Failed to fetch ports for device ${device.serial}: ${portErr.message}`, 'warn'); + // Continue with other devices + } + } + + logger('meraki:ports', `Total ports collected from MS switches: ${allPorts.length}`, 'debug'); + return allPorts; + + } catch (err) { + logger('meraki:ports', `Error fetching port data for network ${networkId}: ${err.message}`, 'error'); + return []; + } +} + +export async function getPortsForStore(storeNumber, relevantSwitchSerials = null) { + logger('meraki:ports', `Getting ports for store ${storeNumber}`, 'debug'); + const network = await findMerakiNetwork(storeNumber); + const networkId = network?.id; + if (!networkId) { + logger('meraki:ports', `No network found for store ${storeNumber}`, 'warn'); + return []; + } + + try { + // Get all devices in the network + const devices = await fetchAllPages(`/networks/${networkId}/devices`); + let msSwitches = devices.filter(d => d.model && d.model.startsWith('MS')); + + if (relevantSwitchSerials && relevantSwitchSerials.size > 0) { + msSwitches = msSwitches.filter(sw => relevantSwitchSerials.has(sw.serial)); + logger('meraki:ports', `Filtered to ${msSwitches.length} relevant switches for ports (to reduce rate limits)`); + } + + let allPorts = []; + + for (const sw of msSwitches) { + try { + logger('meraki:ports', `Fetching ports for switch ${sw.serial} (${sw.name || sw.model})`, 'debug'); + const ports = await fetchAllPages(`/devices/${sw.serial}/switch/ports`); + + const enrichedPorts = ports.map(p => ({ + ...p, + deviceSerial: sw.serial, + deviceName: sw.name || sw.model || 'Unknown Switch', + model: sw.model, + // Explicitly map known fields + portId: p.portId || p.number, + status: p.status || (p.enabled ? 'Enabled' : 'Disabled'), // fallback + accessPolicy: p.accessPolicy || null, // may still be missing + stickyMac: p.stickyMac || false, + allowedMacs: p.allowedMacs || [], + })); + + allPorts = allPorts.concat(enrichedPorts); + } catch (err) { + logger('meraki:ports', `Error fetching ports for ${sw.serial}: ${err.message}`, 'warn'); + } + } + + logger('meraki:ports', `Total ports collected from MS switches: ${allPorts.length}`, 'debug'); + return allPorts; + } catch (err) { + logger('meraki:ports', `Error fetching ports for store ${storeNumber}: ${err.message}`, 'error'); + return []; + } +} + +/** + * Get full Layer-2 topology (nodes + links) from Meraki + * Uses the official Topology API you asked about + */ +export async function getLinkLayerTopology(networkId) { + if (!networkId) { + logger('meraki:topology', 'No networkId provided', 'warn'); + return { nodes: [], links: [], errors: [] }; + } + + try { + logger('meraki:topology', `Fetching linkLayer topology for network ${networkId}`); + + // This endpoint is NOT paginated → single call + const url = `/networks/${networkId}/topology/linkLayer`; + const response = await merakiAxios.get(url); // uses your existing axios instance + + const topology = response.data || { nodes: [], links: [], errors: [] }; + + logger('meraki:topology', + `✅ Received ${topology.nodes?.length || 0} nodes and ${topology.links?.length || 0} links`); + + return topology; + + } catch (err) { + logger('meraki:topology', `Failed to fetch topology: ${err.message}`, 'error'); + return { nodes: [], links: [], errors: [err.message] }; + } +} + +export async function getWirelessClientConnectionStats(networkId, clientId) { + const timespan = 86400; // 24 hours. Use 604800 for full 7 days if you want more history + + try { + const response = await merakiAxios.get( + `/networks/${networkId}/wireless/clients/${clientId}/connectionStats?timespan=${timespan}` + ); + + logger('meraki:clients', 'Connection stats response received'); + + // Extract the nested connectionStats object, or return empty + const rawStats = response.data?.connectionStats || {}; + + return { + assoc: rawStats.assoc || 0, + auth: rawStats.auth || 0, + dhcp: rawStats.dhcp || 0, + dns: rawStats.dns || 0, + success: rawStats.success || 0 + }; + + } catch (err) { + logger('meraki', `Connection stats failed for ${clientId}: ${err.message}`, 'warn'); + return { assoc: 0, auth: 0, dhcp: 0, dns: 0, success: 0 }; + } +} + +export async function getWirelessClientHealthScores(networkId, clientId) { + try { + const response = await merakiAxios.get( + `/networks/${networkId}/wireless/clients/${clientId}/healthScores` + ); + + logger('meraki:clients', 'Health scores response received'); + + return response.data || {}; + + } catch (err) { + logger('meraki', `Health scores failed for ${clientId}: ${err.message}`, 'warn'); + return {}; + } +} +/** + * Get port configuration for a specific switch port + */ +export async function getSwitchPortConfig(serial, portId) { + try { + const res = await merakiAxios.get(`/devices/${serial}/switch/ports/${portId}`); + return res.data; + } catch (err) { + logger('meraki:ports', `Port config failed for ${serial}:${portId}`, 'warn'); + return null; + } +} + +/** + * Get port status for a specific switch port (uses cached statuses per switch) + * Note: We still use the builder's portStatusCache for now, but this can be moved later if desired. + */ +export async function getSwitchPortStatus(serial, portId, portStatusCache) { + if (!portStatusCache.has(serial)) { + try { + const res = await merakiAxios.get(`/devices/${serial}/switch/ports/statuses`); + portStatusCache.set(serial, res.data || []); + } catch (err) { + logger('meraki:ports', `Port statuses failed for switch ${serial}`, 'warn'); + portStatusCache.set(serial, []); + } + } + + const statuses = portStatusCache.get(serial) || []; + return statuses.find(p => String(p.portId || p.number) === String(portId)) || null; +} + +/** + * Get *all* port statuses for a switch in one call (batch /statuses). + * Returns the array directly (for use in switches map for chat path etc). + */ +export async function getSwitchPortsStatuses(serial) { + try { + const res = await merakiAxios.get(`/devices/${serial}/switch/ports/statuses`); + return res.data || []; + } catch (err) { + logger('meraki:ports', `Failed to get port statuses for switch ${serial}: ${err.message}`, 'warn'); + return []; + } +} + +/** + * Enrich a single AV device with Meraki client + port data + * This centralizes all client matching and port fetching + */ +export async function enrichDeviceWithMeraki(device, allMerakiClients, portStatusCache) { + // Delegate fully to shared (unified) + // Note: portStatusCache passed through + await attachMerakiClientWithPorts(device, allMerakiClients, [], portStatusCache); // portConfigs empty here, or pass if available + // The shared attachMerakiClientWithPorts will set client + ports + // We keep wireless null as before + if (device.meraki) { + device.meraki.wirelessDetails = device.meraki.wirelessDetails || null; + device.meraki.wirelessSummary = device.meraki.wirelessSummary || null; + } + return device; +} + +export default { + getMerakiClients, + getClientsForStore, + getMerakiPorts, + getPortsForStore, + getWirelessClientConnectionStats, + getWirelessClientHealthScores, + getSwitchPortConfig, + getSwitchPortStatus, + getSwitchPortsStatuses, + enrichDeviceWithMeraki // new +}; \ No newline at end of file diff --git a/integrations/meraki/devices.js b/integrations/meraki/devices.js new file mode 100644 index 0000000..8d056eb --- /dev/null +++ b/integrations/meraki/devices.js @@ -0,0 +1,191 @@ +// src/integrations/meraki/devices.js +import { merakiAxios } from './client.js'; +import { logger } from '../../utils/logger.js'; + +/** + * Get simple list of all devices in a Meraki network (switches + APs) + */ +export async function getAllMerakiDevices(networkId) { + if (!networkId) return []; + try { + const res = await merakiAxios.get(`/networks/${networkId}/devices`); + return res.data || []; + } catch (err) { + logger('meraki:devices', `Failed to fetch device list for network ${networkId}: ${err.message}`, 'warn'); + return []; + } +} + +/** + * Get full details for a single Meraki device (switch or AP) + */ +export async function getMerakiDeviceDetail(serial) { + try { + const res = await merakiAxios.get(`/devices/${serial}`); + return res.data; + } catch (err) { + logger('meraki:devices', `Failed to get detail for device ${serial}: ${err.message}`, 'warn'); + return { serial, error: err.message }; + } +} + +/** + * Get wireless status for an AP (Tx power, channels, client counts, etc.) + * Returns null gracefully for non-AP devices (switches) + */ +export async function getMerakiWirelessStatus(serial) { + try { + const res = await merakiAxios.get(`/devices/${serial}/wireless/status`); + return res.data; + } catch (err) { + // 404 is expected for switches — treat as normal + if (err.response?.status === 404) { + return null; + } + logger('meraki:devices', `Wireless status failed for ${serial}: ${err.message}`, 'debug'); + return null; + } +} + +/** + * Enrich a set of referenced Meraki devices + * Returns map: serial → full device object + wirelessStatus (for APs) + */ +export async function enrichMerakiDevices(networkId, referencedSerials) { + if (!networkId || !referencedSerials?.size) return {}; + + const detailsMap = {}; + logger('meraki:devices', `Enriching ${referencedSerials.size} Meraki devices with full details + wireless status`); + + for (const serial of referencedSerials) { + try { + const [basicRes, wirelessRes] = await Promise.allSettled([ + getMerakiDeviceDetail(serial), + getMerakiWirelessStatus(serial) + ]); + + const fullDevice = basicRes.status === 'fulfilled' ? basicRes.value : { serial }; + + if (wirelessRes.status === 'fulfilled' && wirelessRes.value) { + fullDevice.wirelessStatus = wirelessRes.value; + } + + detailsMap[serial] = fullDevice; + } catch (err) { + logger('meraki:devices', `Failed enriching device ${serial}: ${err.message}`, 'warn'); + detailsMap[serial] = { serial, error: err.message }; + } + } + + return detailsMap; +} + +/** + * Get recent signal quality (RSSI + SNR) for a wireless client + * Uses the exact endpoint and parameters you provided (1-hour resolution) + */ +export async function getWirelessClientSignalQuality(networkId, clientId) { + if (!networkId || !clientId) return { rssi: null, snr: null }; + + try { + const timespan = 3600; // 1 hour for near-realtime + const url = `/networks/${networkId}/wireless/signalQualityHistory?clientId=${clientId}×pan=${timespan}&perPage=1&resolution=3600`; + + const response = await merakiAxios.get(url); + const history = response.data || []; + + const latest = history.length > 0 ? history[history.length - 1] : null; + + return { + rssi: latest?.rssi ?? null, // e.g. -45 + snr: latest?.snr ?? null // e.g. 50 + }; + } catch (err) { + logger('meraki:devices', `Signal quality failed for client ${clientId}: ${err.message}`, 'debug'); + return { rssi: null, snr: null }; + } +} + +/** + * Get recent average latency for a wireless client + */ +export async function getWirelessClientLatency(networkId, clientId) { + if (!networkId || !clientId) return { avgLatencyMs: null }; + + try { + const timespan = 3600; // 1 hour + const url = `/networks/${networkId}/wireless/latencyHistory?clientId=${clientId}×pan=${timespan}&perPage=1&resolution=3600`; + + const response = await merakiAxios.get(url); + const history = response.data || []; + + const latest = history.length > 0 ? history[history.length - 1] : null; + + return { + avgLatencyMs: latest?.avgLatencyMs ?? null + }; + } catch (err) { + logger('meraki:devices', `Latency history failed for client ${clientId}: ${err.message}`, 'debug'); + return { avgLatencyMs: null }; + } +} + +/** + * Get failed connection attempts for a client (last 7 days) + * Returns empty array if none + */ +export async function getWirelessClientFailedConnections(networkId, clientId) { + if (!networkId || !clientId) return []; + + try { + const timespan = 604800; // 7 days + const url = `/networks/${networkId}/wireless/failedConnections?clientId=${clientId}×pan=${timespan}`; + + const response = await merakiAxios.get(url); + return response.data || []; + } catch (err) { + logger('meraki:devices', `Failed connections query failed for client ${clientId}: ${err.message}`, 'debug'); + return []; + } +} + +/** + * Get Link Layer Topology for the network + * This is the endpoint that returns nodes + links suitable for Mermaid diagrams + */ +export async function getMerakiTopology(networkId) { + if (!networkId) { + return { nodes: [], links: [], errors: ["No networkId provided"] }; + } + + try { + logger('meraki:topology', `Fetching linkLayer topology for network ${networkId}`); + + const response = await merakiAxios.get(`/networks/${networkId}/topology/linkLayer`); + const topology = response.data || { nodes: [], links: [], errors: [] }; + + logger('meraki:topology', + `Received ${topology.nodes?.length || 0} nodes and ${topology.links?.length || 0} links`); + + return topology; + + } catch (err) { + logger('meraki:topology', `Failed to fetch topology for ${networkId}: ${err.message}`, 'warn'); + return { + nodes: [], + links: [], + errors: [err.message] + }; + } +} + +export default { + getAllMerakiDevices, + getMerakiDeviceDetail, + getMerakiWirelessStatus, + enrichMerakiDevices, + getWirelessClientSignalQuality, + getWirelessClientLatency, + getWirelessClientFailedConnections, + getMerakiTopology +}; \ No newline at end of file diff --git a/integrations/meraki/index.js b/integrations/meraki/index.js new file mode 100644 index 0000000..d402d32 --- /dev/null +++ b/integrations/meraki/index.js @@ -0,0 +1,3 @@ +export * from './client.js'; +export * from './networks.js'; +export * from './clients.js'; \ No newline at end of file diff --git a/integrations/meraki/networks.js b/integrations/meraki/networks.js new file mode 100644 index 0000000..7f94c2b --- /dev/null +++ b/integrations/meraki/networks.js @@ -0,0 +1,81 @@ +// src/integrations/meraki/networks.js + +import { fetchAllPages } from './client.js'; +import { logger } from '../../utils/logger.js'; + +// In-memory cache +let cachedNetworks = []; +let lastCacheTime = 0; +const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours + +/** + * Refresh the full list of networks (called by cron or on cache miss) + */ +export async function refreshMerakiNetworksCache() { + const start = Date.now(); + logger('meraki:networks', 'Refreshing cache', 'debug'); + + try { + const url = `/organizations/${process.env.MERAKI_ORG_ID}/networks?perPage=5000`; + cachedNetworks = await fetchAllPages(url); + lastCacheTime = Date.now(); + + logger('meraki:networks', `Cached ${cachedNetworks.length} networks (${Date.now() - start} ms)`, 'debug'); + } catch (err) { + logger('meraki:networks', `Cache refresh failed: ${err.message}`, 'warn'); + // Keep old cache if it exists + } +} + +/** + * Get cached networks (auto-refresh if stale or empty) + */ +export async function getMerakiNetworks(forceRefresh = false) { + const now = Date.now(); + if (forceRefresh || !cachedNetworks.length || (now - lastCacheTime > CACHE_TTL_MS)) { + await refreshMerakiNetworksCache(); + } + return cachedNetworks; +} + +/** + * Find Meraki network by store number — returns the FULL network object (contains .url, .name, .id, etc.) + * @param {string|number} storeNum + * @returns {object|null} full network object or null + */ +export async function findMerakiNetwork(storeNum) { // ← renamed for clarity + if (!storeNum) return null; + + const raw = String(storeNum).trim(); + let searchTerm = raw.match(/\d+/)[0]; + searchTerm = searchTerm.padStart(5, '0').slice(-5); + + const networks = await getMerakiNetworks(); + + logger('meraki:find', `Searching store "${raw}" → using 5-digit term "${searchTerm}"`, 'debug'); + + let bestMatch = null; + let bestScore = -1; + + for (const net of networks) { + const name = (net.name || '').toLowerCase(); + const term = searchTerm.toLowerCase(); + + if (name.includes(term)) { + const score = (name.includes(` ${term}`) || name.includes(`-${term}`) || name.includes(term)) ? 100 : 50; + if (score > bestScore) { + bestScore = score; + bestMatch = net; // ← full object + } + } + } + + if (bestMatch) { + logger('meraki:find', `✅ Best match: ${bestMatch.name} (ID: ${bestMatch.id})`, 'debug'); + logger('meraki:networks', `Best match for store ${storeNum}: ${bestMatch?.name || 'none'}`, 'debug'); + return bestMatch; // ← return full network + } + + logger('meraki:find', `❌ No match found for 5-digit term "${searchTerm}"`, 'warn'); + return null; +} \ No newline at end of file diff --git a/integrations/optisigns/client.js b/integrations/optisigns/client.js new file mode 100644 index 0000000..e955bcc --- /dev/null +++ b/integrations/optisigns/client.js @@ -0,0 +1,154 @@ +// src/integrations/optisigns/client.js +import { GraphQLClient, gql } from 'graphql-request'; +import { logger } from '../../utils/logger.js'; + +// Global caches (populated from bulk fetch in getOptiSignStatus) +const playlistById = new Map(); +const assetById = new Map(); + +/** + * Update global caches from the playlistMap returned by getOptiSignStatus + */ +export function updateOptiSignsCaches(playlistMap) { + playlistMap.forEach((name, id) => { + playlistById.set(id, name); + }); + logger('optisigns:client', `Updated global cache with ${playlistMap.size} playlists`, 'debug'); +} + +const client = new GraphQLClient('https://graphql-gateway.optisigns.com/graphql', { + headers: { + Authorization: `Bearer ${process.env.OPTISIGN_API_KEY}` + }, +}); + +/** + * Fetch OptiSigns devices and playlists for a store + */ +export async function getOptiSignStatus(storeNumber) { + const paddedStore = String(storeNumber).padStart(6, '0'); + logger('optisigns:client', `Fetching status for store ${storeNumber} (padded: ${paddedStore})`, 'debug'); + + let storeDevices = []; + const playlistMap = new Map(); + + try { + // 1. Fetch all devices with pagination + logger('optisigns:client', 'Fetching all devices...', 'debug'); + let allDevices = []; + let after = null; + const pageSize = 50; + + do { + const query = gql` + query GetDevices($first: Int, $after: String) { + devices(query: {}, first: $first, after: $after) { + page { + edges { + node { + _id + deviceName + UUID + pairingCode + currentType + currentAssetId + currentPlaylistId + localAppVersion + lastHeartBeat + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `; + + const data = await client.request(query, { first: pageSize, after }); + const pageEdges = data.devices.page.edges || []; + allDevices = allDevices.concat(pageEdges.map(e => e.node)); + after = data.devices.page.pageInfo.hasNextPage + ? data.devices.page.pageInfo.endCursor + : null; + + logger('optisigns:client', `Fetched page with ${pageEdges.length} devices (after: ${after || 'null'})`, 'debug'); + } while (after); + + logger('optisigns:client', `Total devices fetched: ${allDevices.length}`, 'debug'); + + // Filter devices for this store + storeDevices = allDevices.filter(d => d.deviceName?.includes(paddedStore)); + logger('optisigns:client', `Matching devices for store ${paddedStore}: ${storeDevices.length}`, 'debug'); + + // 2. Fetch all playlists (for name mapping) + logger('optisigns:client', 'Fetching all playlists...', 'debug'); + let allPlaylists = []; + after = null; + + do { + const playlistsData = await client.request(gql` + query GetAllPlaylists($first: Int, $after: String) { + playlists(first: $first, after: $after) { + page { + edges { + node { + _id + name + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, { first: 100, after }); + + const pageEdges = playlistsData.playlists.page.edges || []; + allPlaylists = allPlaylists.concat(pageEdges.map(e => e.node)); + after = playlistsData.playlists.page.pageInfo.hasNextPage + ? playlistsData.playlists.page.pageInfo.endCursor + : null; + } while (after); + + logger('optisigns:client', `Total playlists fetched: ${allPlaylists.length}`, 'debug'); + + // Build playlist name map + allPlaylists.forEach(pl => { + if (pl._id && pl.name) playlistMap.set(pl._id, pl.name); + }); + + // Update global cache so getPlaylistName can use it + updateOptiSignsCaches(playlistMap); + + } catch (err) { + logger('optisigns:client', `Error fetching OptiSigns data: ${err.message}`, 'error'); + if (err.response) { + logger('optisigns:client', `GraphQL response status: ${err.response.status}`, 'error'); + } + } + + return { + devices: storeDevices, + playlistMap + }; +} + +/** + * Fast cached lookup (synchronous) + */ +export function getPlaylistName(playlistId) { + if (!playlistId) return '—'; + return playlistById.get(playlistId) || playlistId; +} + +/** + * Fast cached lookup (synchronous) + */ +export function getAssetName(assetId) { + if (!assetId) return '—'; + return assetById.get(assetId) || assetId; +} \ No newline at end of file diff --git a/integrations/red/client.js b/integrations/red/client.js new file mode 100644 index 0000000..b2a0138 --- /dev/null +++ b/integrations/red/client.js @@ -0,0 +1,46 @@ +// src/integrations/red/client.js + +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + +export const redAxios = axios.create({ + baseURL: process.env.RED_BASE_URL, + timeout: 10000, + headers: { + 'ClientID': process.env.RED_CLIENT_ID, + 'ApiKey': process.env.RED_API_KEY, + 'Accept': 'application/json', + }, +}); + +// Optional request logging +redAxios.interceptors.request.use(cfg => { + logger('red:request', `${cfg.method.toUpperCase()} ${cfg.url} (params: ${JSON.stringify(cfg.params || {})})`, 'debug'); + return cfg; +}); + +redAxios.interceptors.response.use( + res => res, + err => { + const msg = err.response + ? `${err.response.status} – ${JSON.stringify(err.response.data || err.message)}` + : err.message; + logger('red:error', msg); + return Promise.reject(err); + } +); + +/** + * Low-level helper: fetch players status with given params + */ +export async function fetchPlayersStatus(params = {}) { + try { + const response = await redAxios.get('/PlayerService/playersstatus', { params }); + return response.data.PlayersStatus || []; + } catch (err) { + const ctx = err.response?.data?.message || err.message; + throw new Error(`RED playersstatus failed: ${ctx}`); + } +} + +export default redAxios; \ No newline at end of file diff --git a/integrations/red/index.js b/integrations/red/index.js new file mode 100644 index 0000000..cb4c338 --- /dev/null +++ b/integrations/red/index.js @@ -0,0 +1,2 @@ +export * from './client.js'; +export * from './players.js'; \ No newline at end of file diff --git a/integrations/red/players.js b/integrations/red/players.js new file mode 100644 index 0000000..c8e9aba --- /dev/null +++ b/integrations/red/players.js @@ -0,0 +1,120 @@ +// src/integrations/red/players.js + +import { fetchPlayersStatus } from './client.js'; +import { logger } from '../../utils/logger.js'; + +/** + * Get RED player status for one company + search term + * @param {string} storeNum padded 4-digit store number (e.g. "02477") + * @param {string} companyId + * @returns {Promise} active players (Status === "A") + */ +export async function getREDPlayersForCompany(storeNum, companyId) { + const params = { + companyId, + searchString: storeNum, + searchColumn: 'Name', + sortColumn: 'Name', + sortDirection: 'ASC', + exactMatch: false, + includeInactive: true, // we filter active later + }; + + try { + const allPlayers = await fetchPlayersStatus(params); + + // Filter to active only (Status === "A") + const active = allPlayers.filter(p => p.Status === 'A'); + + if (active.length > 0) { + logger('red:players', `Found ${active.length} active players for company ${companyId}, store ${storeNum}`, 'debug'); + } + + return active; + } catch (err) { + logger('red:players', `Failed for company ${companyId}, store ${storeNum}: ${err.message}`); + return []; // soft fail → don't break entire multi-company request + } +} + +/** + * Get all active RED players across multiple companies for a store + * Uses Promise.allSettled so one company failing doesn't kill everything + * @param {string|number} storeNumber e.g. "2477" or 2477 + * @returns {Promise} combined active players from all companies + */ +export async function getREDStatusForStore(storeNumber) { + const start = Date.now(); + const storeNum = String(Number(storeNumber)).padStart(4, '0'); + + logger('red:status', `Collecting status for store ${storeNum}`, 'debug'); + + // Support comma-separated RED_COMPANY_IDS env var (primary) or fall back to empty + const companyIDs = (process.env.RED_COMPANY_IDS || '') + .split(',') + .map(id => id.trim()) + .filter(Boolean); + + if (companyIDs.length === 0) { + logger('red:status', 'No company IDs configured'); + return []; + } + + const results = await Promise.allSettled( + companyIDs.map(cid => getREDPlayersForCompany(storeNum, cid)) + ); + + const allActivePlayers = []; + + results.forEach((result, index) => { + const cid = companyIDs[index]; + if (result.status === 'fulfilled') { + allActivePlayers.push(...result.value); + } else { + logger('red:status', `Company ${cid} failed: ${result.reason?.message || result.reason}`); + } + }); + + logger( + 'red:status', + `Collected ${allActivePlayers.length} active RED players for store ${storeNum} (${Date.now() - start} ms)`, + 'debug' + ); + + return allActivePlayers; +} + +/** + * Format RED players into markdown text (for /deviceStatus or similar) + * @param {Array} players + * @param {string} storeNumber + * @returns {string} markdown + */ +export function formatREDPlayersMarkdown(players, storeNumber) { + if (players.length === 0) { + return `No **active** RED devices found for store ${storeNumber}.\n`; + } + + let md = `# RED Devices – Store ${storeNumber}\n\n`; + + players.forEach(player => { + md += `### ${player.DeviceID || 'Unknown ID'}\n`; + md += `- **Connectivity:** ${player.Connectivity || '—'}\n`; + md += `- **Last Ping:** ${simpleTimeAgo(player.LastPingTimeUTC) || '—'}\n`; + md += `- **Deployment:** ${player.DeploymentStatusName || '—'} | Transition: ${player.StateTransitionStatus || '—'}\n`; + md += `\n---\n\n`; + }); + + return md; +} + +// Reuse your existing time helper (move to utils/time.js later) +function simpleTimeAgo(isoString) { + if (!isoString) return 'Never'; + const date = new Date(isoString.endsWith('Z') ? isoString : isoString + 'Z'); + if (isNaN(date.getTime())) return 'Invalid date'; + const seconds = Math.floor((Date.now() - date) / 1000); + // ... your existing logic for year/month/day/hour/min/sec ago + // (copy-paste or import from utils) + return `${seconds} seconds ago`; // placeholder +} \ No newline at end of file diff --git a/integrations/serviceChannel/attachments.js b/integrations/serviceChannel/attachments.js new file mode 100644 index 0000000..c98e5cc --- /dev/null +++ b/integrations/serviceChannel/attachments.js @@ -0,0 +1,122 @@ +// src/integrations/serviceChannel/attachments.js +import scAxios from './client.js'; +import axios from 'axios'; +import { extname } from 'node:path'; +import { logger } from '../../utils/logger.js'; + +const mimeTypes = { + '.pdf': 'application/pdf', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.txt': 'text/plain', +}; + +function getContentTypeFromFilename(filename) { + if (!filename) return 'application/octet-stream'; + const ext = extname(filename).toLowerCase(); + return mimeTypes[ext] || 'application/octet-stream'; +} + +// ────────────────────────────────────────────── +// Download single attachment by ID +// ────────────────────────────────────────────── +export async function downloadAttachmentById(workOrderId, attachmentId) { + const start = Date.now(); + logger('servicechannel:attachment', `Downloading attachment ${attachmentId} from WO ${workOrderId}`); + + try { + // First get metadata (to get Uri + Name) + const metaRes = await scAxios.get(`/odata/workorders(${workOrderId})/attachments`, { + params: { $filter: `Id eq ${attachmentId}` }, + }); + + const atts = metaRes.data.value || []; + if (atts.length === 0) { + throw new Error(`Attachment ${attachmentId} not found on WO ${workOrderId}`); + } + + const att = atts[0]; + if (!att.Uri) { + throw new Error(`No download URI for attachment ${attachmentId}`); + } + + let fileName = att.Name || `attachment_${attachmentId}`; + if (att.Name && !extname(att.Name)) { + fileName += '.bin'; + } + + // Download the actual file + const fileRes = await axios.get(att.Uri, { responseType: 'arraybuffer' }); + + logger('servicechannel:attachment', + `Successfully downloaded ${fileName} (${Date.now() - start} ms)`); + + return { + success: true, + fileName, + buffer: Buffer.from(fileRes.data), + contentType: getContentTypeFromFilename(fileName), + isInvoiceCopy: !!att.IsInvoiceDigitalCopy, + metadata: att, + }; + } catch (err) { + const msg = err.response + ? `${err.response?.status || 'unknown'} – ${err.message}` + : err.message; + + logger('servicechannel:attachment', + `Failed to download attachment ${attachmentId} from WO ${workOrderId}: ${msg}`, 'error'); + + throw new Error(`Download failed for WO ${workOrderId} / Att ${attachmentId}: ${msg}`); + } +} + +// ────────────────────────────────────────────── +// Attachment Functions +// ────────────────────────────────────────────── + +export async function listWorkOrderAttachments(workOrderId) { + logger('servicechannel:client', `Listing attachments for work order ${workOrderId}`); + + try { + const response = await scAxios.get(`/workorders/${workOrderId}/attachments`); + const attachments = response.data?.value || response.data?.Attachments || response.data || []; + + logger('servicechannel:client', `Found ${attachments.length} attachments for WO ${workOrderId}`); + return attachments; + } catch (err) { + logger('servicechannel:client', `Failed to list attachments for WO ${workOrderId}: ${err.message}`, 'error'); + return []; + } +} + +export async function getWorkOrderAttachments(woId) { + logger('servicechannel:client', `Fetching attachments for work order ${woId}`); + + try { + const response = await scAxios.get(`/workorders/${woId}/attachments`); + const attachments = response.data?.value || response.data?.Attachments || response.data || []; + + logger('servicechannel:client', `Found ${attachments.length} attachments for WO ${woId}`); + + return attachments.map(att => ({ + id: att.Id || att.AttachmentId, + fileName: att.FileName || att.Name || att.OriginalFileName || 'attachment', + fileType: att.ContentType || att.MimeType || 'application/octet-stream', + fileSize: att.FileSize ? `${(att.FileSize / 1024).toFixed(1)} KB` : 'Unknown size', + uploadedDate: att.CreatedDateTime || att.UploadedOn || att.DateCreated + ? new Date(att.CreatedDateTime || att.UploadedOn || att.DateCreated).toLocaleString('en-US') + : 'Unknown', + downloadUrl: att.DownloadUrl || att.Url || null + })); + } catch (err) { + logger('servicechannel:client', `Error fetching attachments for WO ${woId}: ${err.message}`, 'error'); + return []; + } +} \ No newline at end of file diff --git a/integrations/serviceChannel/client.js b/integrations/serviceChannel/client.js new file mode 100644 index 0000000..4b7c1a7 --- /dev/null +++ b/integrations/serviceChannel/client.js @@ -0,0 +1,206 @@ +// src/integrations/serviceChannel/client.js +import axios from 'axios'; +import { Mutex } from 'async-mutex'; +import { logger } from '../../utils/logger.js'; + +const mutex = new Mutex(); +let cachedToken = null; +let tokenExpiresAt = 0; + +// ────────────────────────────────────────────── +// Dedicated axios instance for ServiceChannel +// ────────────────────────────────────────────── +export const scAxios = axios.create({ + baseURL: process.env.SC_BASE_URL, + timeout: 15000, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, +}); + +// Automatic token injection + refresh on 401 +scAxios.interceptors.request.use(async (cfg) => { + if (!cfg.headers.Authorization) { + const token = await getServiceChannelToken(); + cfg.headers.Authorization = `Bearer ${token}`; + } + return cfg; +}); + +scAxios.interceptors.response.use( + response => response, + async (error) => { + if (error.response?.status === 401) { + logger('servicechannel:client', '401 detected → forcing token refresh', 'warn'); + await getServiceChannelToken(true); // force refresh + + // Retry once with new token + const originalRequest = error.config; + if (!originalRequest._retry) { + originalRequest._retry = true; + originalRequest.headers.Authorization = `Bearer ${cachedToken}`; + return scAxios(originalRequest); + } + } + return Promise.reject(error); + } +); + +// ────────────────────────────────────────────── +// Token management – cached + mutex-protected +// ────────────────────────────────────────────── +export async function getServiceChannelToken(forceRefresh = false) { + const release = await mutex.acquire(); + + try { + const now = Date.now(); + + if (!forceRefresh && cachedToken && now < tokenExpiresAt) { + return cachedToken; + } + + logger('servicechannel:client', 'Fetching new ServiceChannel token'); + + const basicAuth = Buffer.from( + `${process.env.SC_CLIENT_ID}:${process.env.SC_CLIENT_SECRET}` + ).toString('base64'); + + const response = await axios.post( + process.env.SC_OAUTH_URL, + new URLSearchParams({ + grant_type: 'password', + username: process.env.SC_USERNAME, + password: process.env.SC_PASSWORD, + }).toString(), + { + headers: { + 'Authorization': `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + timeout: 10000, + } + ); + + const { access_token, expires_in } = response.data; + + cachedToken = access_token; + tokenExpiresAt = now + (expires_in * 1000) - 300_000; // refresh 5 min early + + logger('servicechannel:client', `New token acquired (expires in ~${Math.round(expires_in / 60)} minutes)`); + return access_token; + + } catch (err) { + const msg = err.response + ? `${err.response.status} – ${JSON.stringify(err.response.data)}` + : err.message; + + logger('servicechannel:client', `Token fetch failed: ${msg}`, 'error'); + throw new Error(`ServiceChannel token fetch failed: ${msg}`); + } finally { + release(); + } +} + +// ────────────────────────────────────────────── +// Simple health-check / token validation +// ────────────────────────────────────────────── +export async function validateToken() { + try { + await scAxios.get('/workorders?$top=1'); + logger('servicechannel:client', 'Token validation successful'); + return true; + } catch (err) { + logger('servicechannel:client', `Token validation failed: ${err.message}`, 'warn'); + return false; + } +} + +// ────────────────────────────────────────────── +// Work Order Search Functions +// ────────────────────────────────────────────── + +export async function searchServiceChannelAVWorkOrders(storeNumber) { + logger('servicechannel:client', `Searching AV work orders for store ${storeNumber}`); + + try { + const threeYearsAgo = new Date(); + threeYearsAgo.setFullYear(threeYearsAgo.getFullYear() - 3); + const fromDate = threeYearsAgo.toISOString().split('T')[0]; + + const storeNum = storeNumber.padStart(6, "0"); + + const response = await scAxios.get('/workorders', { + params: { + 'storeId': storeNum, + 'trade': 'Audio' + } + }); + + const rawData = response.data.value || response.data || []; + logger('servicechannel:client', `Found ${rawData.length} AV work orders for store ${storeNumber}`); + + return rawData.map(wo => ({ + id: wo.Id, + woNumber: wo.WorkorderNumber, + summary: wo.ShortDescription || 'No summary', + status: `${wo.Status?.Primary || 'Unknown'}/${wo.Status?.Extended || 'Unknown'}`, + openedDate: wo.CreatedDate ? new Date(wo.CreatedDate).toLocaleDateString() : 'Unknown', + totalInvoiceCost: Number(wo.Nte || 0) + })); + } catch (err) { + logger('servicechannel:client', `Work order search error for store ${storeNumber}: ${err.message}`, 'error'); + if (err.response) { + logger('servicechannel:client', `Response status: ${err.response.status}`, 'error'); + } + return []; + } +} + +export async function getWorkOrderNotes(woId) { + logger('servicechannel:client', `Fetching notes for work order ${woId}`); + + try { + const response = await scAxios.get(`/workorders/${woId}/notes`, { + params: { + "paging": "1:9999" + } + }); + + const notesArray = response.data.Notes || response.data.value || response.data || []; + + if (!Array.isArray(notesArray)) { + logger('servicechannel:client', `Notes response is not an array for WO ${woId}`, 'warn'); + return []; + } + + logger('servicechannel:client', `Retrieved ${notesArray.length} notes for WO ${woId}`); + return notesArray.map(note => ({ + date: note.DateCreated ? new Date(note.DateCreated).toLocaleString() : 'Unknown', + text: note.NoteData || '', + createdBy: note.CreatedBy || 'Unknown' + })); + } catch (err) { + logger('servicechannel:client', `Failed to fetch notes for WO ${woId}: ${err.message}`, 'error'); + return []; + } +} + +export async function getWorkOrderDetails(woId) { + logger('servicechannel:client', `Fetching full details for work order ${woId}`); + + try { + const response = await scAxios.get(`/workorders/${woId}`); + return response.data; + } catch (err) { + logger('servicechannel:client', `Error fetching details for WO ${woId}: ${err.message}`, 'error'); + return null; + } +} + +export { + listWorkOrderAttachments, + downloadAttachmentById, + getWorkOrderAttachments +} from './attachments.js'; +export default scAxios; \ No newline at end of file diff --git a/integrations/serviceChannel/index.js b/integrations/serviceChannel/index.js new file mode 100644 index 0000000..c400fcb --- /dev/null +++ b/integrations/serviceChannel/index.js @@ -0,0 +1,4 @@ +// src/integrations/serviceChannel/index.js + +export * from './client.js'; +export * from './attachments.js'; \ No newline at end of file diff --git a/integrations/serviceChannel/types.js b/integrations/serviceChannel/types.js new file mode 100644 index 0000000..e69de29 diff --git a/integrations/webex/BotClient.js b/integrations/webex/BotClient.js new file mode 100644 index 0000000..c08a48a --- /dev/null +++ b/integrations/webex/BotClient.js @@ -0,0 +1,117 @@ +// src/integrations/webex/BotClient.js +import axios from 'axios'; +import FormData from 'form-data'; +import { logger } from '../../utils/logger.js'; + +class BotClient { + static #instance = null; + + constructor() { + if (BotClient.#instance) { + return BotClient.#instance; + } + + const token = process.env.WEBEX_BOT_TOKEN; + const baseURL = process.env.WEBEX_BASE_URL || 'https://webexapis.com/v1'; + + if (!token) { + logger('webex:bot', 'WEBEX_BOT_TOKEN is missing – messaging will fail', 'error'); + throw new Error('Missing WEBEX_BOT_TOKEN environment variable'); + } + + this.axios = axios.create({ + baseURL, + timeout: 15000, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + // Global error interceptor for better logging + this.axios.interceptors.response.use( + (response) => response, + (err) => { + const msg = err.response + ? `${err.response.status} - ${JSON.stringify(err.response.data || {})}` + : err.message; + logger('webex:bot', `API error: ${msg}`, 'error'); + return Promise.reject(err); + } + ); + + BotClient.#instance = this; + logger('webex:bot', 'BotClient initialized with bot token from environment'); + } + + async sendMarkdown(roomId, markdown, textFallback = null) { + if (!roomId || !markdown) { + logger('webex:bot', 'sendMarkdown called with missing roomId or markdown', 'warn'); + return null; + } + + const payload = { roomId, markdown }; + if (textFallback) payload.text = textFallback; + + try { + const response = await this.axios.post('/messages', payload); + logger('webex:bot', `Sent markdown message to room ${roomId.slice(0, 8)}...`); + return response.data; + } catch (err) { + // Error already logged by interceptor + throw err; + } + } + + async sendWithAttachment(roomId, buffer, fileName, contentType = 'application/octet-stream', text = 'Attached file') { + if (!roomId || !buffer || !fileName) { + logger('webex:bot', 'sendWithAttachment called with missing parameters', 'warn'); + return null; + } + + try { + const form = new FormData(); + form.append('roomId', roomId); + form.append('text', text); + form.append('files', buffer, { + filename: fileName, + contentType: contentType + }); + + const response = await this.axios.post('/messages', form, { + headers: form.getHeaders(), // Let form-data set the correct Content-Type with boundary + }); + + logger('webex:bot', `Sent attachment "${fileName}" to room ${roomId.slice(0, 8)}...`); + return response.data; + } catch (err) { + logger('webex:bot', `Failed to send attachment ${fileName}: ${err.message}`, 'error'); + throw err; + } + } + + async getRoomDetails(roomId) { + try { + const response = await this.axios.get(`/rooms/${roomId}`); + logger('webex:bot', `Retrieved details for room ${roomId}`); + return response.data; + } catch (err) { + logger('webex:bot', `Failed to get room details for ${roomId}: ${err.message}`, 'warn'); + return null; + } + } + + async addMemberToRoom(roomId, personEmail) { + try { + const response = await this.axios.post('/memberships', { roomId, personEmail }); + logger('webex:bot', `Added member ${personEmail} to room ${roomId}`); + return response.data; + } catch (err) { + logger('webex:bot', `Failed to add member ${personEmail} to room ${roomId}: ${err.message}`, 'error'); + throw err; + } + } +} + +// Export singleton instance +export default new BotClient(); \ No newline at end of file diff --git a/integrations/webex/WebexClient.js b/integrations/webex/WebexClient.js new file mode 100644 index 0000000..45f127c --- /dev/null +++ b/integrations/webex/WebexClient.js @@ -0,0 +1,187 @@ +// integrations/webex/WebexClient.js +import axios from 'axios'; +import WebexServiceAppAuth from './WebexServiceAppAuth.js'; +import { logger } from '../../utils/logger.js'; // ← Your new custom logger + +class WebexClient { + constructor() { + this.auth = new WebexServiceAppAuth(); + this.baseURL = 'https://webexapis.com/v1'; + logger('webex:client', 'WebexClient initialized with Service App auth'); + } + + async request(method, endpoint, data = null, params = null) { + const { data: body } = await this.requestRaw(method, endpoint, data, params); + return body; + } + + // Like `request()` but returns `{ data, headers, status }`. Use this when + // callers need response headers — most notably the `Link` header for + // Webex's cursor-based pagination (`Link: <…?next=cursor>; rel="next"`). + // If `endpointOrUrl` looks like an absolute URL (e.g. a `Link: <…>; rel="next"` + // value extracted from a previous page), it's used as-is and `params` are + // ignored — the URL already carries the cursor. Otherwise it's treated as + // a path relative to `this.baseURL`. + async requestRaw(method, endpointOrUrl, data = null, params = null) { + const token = await this.auth.getAccessToken(); + const isAbsolute = /^https?:\/\//i.test(endpointOrUrl); + const url = isAbsolute ? endpointOrUrl : `${this.baseURL}/${endpointOrUrl}`; + + try { + const response = await axios({ + method, + url, + headers: { Authorization: `Bearer ${token}` }, + data, + params: isAbsolute ? undefined : params, + }); + return { data: response.data, headers: response.headers, status: response.status }; + } catch (err) { + if (err.response?.status === 401) { + logger('webex:client', '401 received from Webex — forcing token refresh', 'warn'); + await this.auth.forceRefresh(); // serialized through auth mutex + return this.requestRaw(method, endpointOrUrl, data, params); // retry once + } + + logger('webex:client', `API error on ${endpointOrUrl}: ${err.message}`, 'error'); + if (err.response?.data) { + logger('webex:client', `Response data: ${JSON.stringify(err.response.data)}`, 'error'); + } + throw err; + } + } + + // Convenience wrappers + async getMe() { + return this.request('GET', 'people/me'); + } + + async listMessages(roomId, options = {}) { + return this.request('GET', 'messages', null, { roomId, ...options }); + } + + async createMessage(roomId, textOrObject) { + const payload = typeof textOrObject === 'string' + ? { roomId, text: textOrObject } + : { roomId, ...textOrObject }; + return this.request('POST', 'messages', payload); + } + + // Add more as needed + async listRooms(max = 100) { + return this.request('GET', 'rooms', null, { max }); + } + + async getRoom(roomId) { + return this.request('GET', `rooms/${roomId}`); + } + + // ── People lookup ────────────────────────────────────────────────────────── + // Returns the first person matching the given email, or null if none found. + // Routes through this.request() so it inherits auth + mutex + 401 retry. + // + // CAUTION: the list endpoint (`GET /v1/people?email=…`) returns a + // *partial* person record — admin-only fields such as `licenses`, `roles`, + // and `siteUrls` are only populated when fetching a single person via + // `GET /v1/people/{id}` (see `getPerson` below). If you need any of those + // fields, call `getPerson(returnedUser.id)` instead of trusting this + // result. The Webex API does this on purpose for list-endpoint + // performance. + async findPersonByEmail(email) { + const data = await this.request('GET', 'people', null, { email }); + return data.items?.[0] || null; + } + + // ── Authorizations (admin-only) ──────────────────────────────────────────── + // List and revoke a user's OAuth authorizations. Requires the service app to + // have the `identity:tokens_read` + `identity:tokens_write` scopes *and* the + // signed-in admin to have Full / User / Device Admin role. See + // https://developer.webex.com/admin/docs/api/v1/authorizations + async listAuthorizations(personId) { + return this.request('GET', 'authorizations', null, { personId }); + } + + async deleteAuthorization(authorizationId) { + return this.request('DELETE', `authorizations/${authorizationId}`); + } + + // ── People (admin) ───────────────────────────────────────────────────────── + // Full person record including the `licenses` array (license IDs the user + // currently holds). Used to determine whether a user already has a meeting + // license on a given site. + async getPerson(personId) { + return this.request('GET', `people/${personId}`); + } + + // ── Licenses (admin) ─────────────────────────────────────────────────────── + // List org licenses. Each item carries `{ id, name, totalUnits, + // consumedUnits, subscriptionId, siteUrl, siteType }`. Requires the service + // app to hold the `spark-admin:licenses_read` scope. + async listLicenses(orgId = null) { + return this.request('GET', 'licenses', null, orgId ? { orgId } : null); + } + + // Assign / remove licenses on a single user. Endpoint is `licenses/users` + // (not `/licenses/people` — confirmed via the wxc_sdk source). Body shape: + // { + // personId: '...', // OR email + // licenses: [{ id, operation: 'add'|'remove', properties? }], + // siteUrls: [{ siteUrl, accountType: 'attendee', operation }], + // orgId?: '...', + // } + // Returns 200 (full success) or 206 (partial) with `{ licenses[], + // pendingLicenses[], siteUrls[], pendingSiteUrls[] }`. Requires + // `spark-admin:people_write`. + async assignLicensesToUser({ personId, email, licenses, siteUrls, orgId } = {}) { + const body = {}; + if (email) body.email = email; + if (personId) body.personId = personId; + if (orgId) body.orgId = orgId; + if (licenses) body.licenses = licenses; + if (siteUrls) body.siteUrls = siteUrls; + return this.request('PATCH', 'licenses/users', body); + } + + // Returns the full deduplicated list of users assigned to a license, + // following Webex's `Link: <…>; rel="next"` cursor pagination. Each entry: + // { id, type: 'INTERNAL'|'EXTERNAL', displayName?, email? } + // This is the reliable way to determine whether a specific user holds a + // specific license — the per-person `licenses` field on `/v1/people/{id}` + // is unreliable for service-app tokens (returns empty even for assigned + // users). Requires `spark-admin:licenses_read`. + async listLicenseAssignees(licenseId, { pageSize = 300 } = {}) { + const all = []; + let { data, headers } = await this.requestRaw( + 'GET', + `licenses/${licenseId}`, + null, + { includeAssignedTo: 'user', limit: pageSize }, + ); + if (Array.isArray(data?.users)) all.push(...data.users); + + let nextUrl = parseLinkNext(headers?.link || headers?.Link); + while (nextUrl) { + ({ data, headers } = await this.requestRaw('GET', nextUrl)); + if (Array.isArray(data?.users)) all.push(...data.users); + nextUrl = parseLinkNext(headers?.link || headers?.Link); + } + return all; + } +} + +// Parses a Webex `Link` header (RFC 5988) and returns the URL of the `next` +// page, or null. Webex headers look like: +// Link: ; rel="next" +function parseLinkNext(linkHeader) { + if (!linkHeader || typeof linkHeader !== 'string') return null; + // Tolerate multiple link entries (comma-separated) by splitting and matching each. + for (const part of linkHeader.split(',')) { + const m = part.match(/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i); + if (m) return m[1]; + } + return null; +} + +// Create and export the singleton instance +const webex = new WebexClient(); +export default webex; \ No newline at end of file diff --git a/integrations/webex/WebexServiceAppAuth.js b/integrations/webex/WebexServiceAppAuth.js new file mode 100644 index 0000000..7cf8197 --- /dev/null +++ b/integrations/webex/WebexServiceAppAuth.js @@ -0,0 +1,209 @@ +// src/integrations/webex/WebexServiceAppAuth.js +import axios from 'axios'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { Mutex } from 'async-mutex'; +import { logger } from '../../utils/logger.js'; // ← Your new custom logger + +class WebexServiceAppAuth { + static #instance = null; + + constructor({ + clientId = process.env.WEBEX_CLIENT_ID, + clientSecret = process.env.WEBEX_CLIENT_SECRET, + tokensFilePath = process.env.WEBEX_TOKENS_PATH + || path.join(process.cwd(), 'tokens', 'webex-service-tokens.json'), + } = {}) { + // Singleton pattern + if (WebexServiceAppAuth.#instance) { + return WebexServiceAppAuth.#instance; + } + + // Required validation + if (!clientId) { + throw new Error('WEBEX_CLIENT_ID is required (set it in environment variables)'); + } + if (!clientSecret) { + throw new Error('WEBEX_CLIENT_SECRET is required (set it in environment variables)'); + } + + this.clientId = clientId; + this.clientSecret = clientSecret; + // Always resolve to an absolute path (based on cwd at startup). + // This makes error logs and fs operations unambiguous whether running locally or in Docker. + // .env should prefer a *relative* path like ./config/webex-service-tokens.json + // so the same .env works both on host (cwd=project root) and inside container (cwd=/app + volume mount). + this.tokensFilePath = path.resolve(tokensFilePath); + + // Token state + this.accessToken = null; + this.refreshToken = null; + this.expiresAt = 0; // Unix timestamp in ms + + // Serializes loadTokens() + refresh() so concurrent callers (cron, webhook, + // HTTP request, framework event firing at the same time) don't issue + // parallel refresh requests. Cisco rotates the refresh_token on every use, + // so a race here would invalidate one of the in-flight refreshes and we'd + // lose our credentials until manual re-bootstrap. + this._authMutex = new Mutex(); + + WebexServiceAppAuth.#instance = this; + + logger('webex:auth', 'WebexServiceAppAuth initialized with environment variables'); + } + + /** + * Load persisted tokens from file + */ + async loadTokens() { + try { + const data = await fs.readFile(this.tokensFilePath, 'utf8'); + const tokens = JSON.parse(data); + + this.accessToken = tokens.accessToken; + this.refreshToken = tokens.refreshToken; + this.expiresAt = tokens.expiresAt || 0; + + logger('webex:auth', 'Webex tokens successfully loaded from file'); + } catch (err) { + if (err.code === 'ENOENT') { + logger('webex:auth', `No tokens file found at ${this.tokensFilePath}`, 'warn'); + logger('webex:auth', 'You need to bootstrap initial tokens once (see documentation)', 'warn'); + } else { + logger('webex:auth', `Failed to load tokens file: ${err.message}`, 'error'); + } + throw err; + } + } + + /** + * Save current token state to file + */ + async saveTokens() { + const payload = { + accessToken: this.accessToken, + refreshToken: this.refreshToken, + expiresAt: this.expiresAt, + updatedAt: new Date().toISOString(), + }; + + try { + await fs.mkdir(path.dirname(this.tokensFilePath), { recursive: true }); + await fs.writeFile(this.tokensFilePath, JSON.stringify(payload, null, 2), 'utf8'); + logger('webex:auth', `Webex tokens saved to ${this.tokensFilePath}`); + } catch (err) { + logger('webex:auth', `Failed to save tokens: ${err.message}`, 'error'); + throw err; + } + } + + /** + * Refresh access token using the current refresh token + */ + async refresh() { + if (!this.refreshToken) { + throw new Error( + 'No refresh token available. ' + + 'Bootstrap initial access_token + refresh_token first ' + + '(via Developer Portal or Applications Token API).' + ); + } + + try { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.clientId, + client_secret: this.clientSecret, + refresh_token: this.refreshToken, + }); + + const response = await axios.post('https://webexapis.com/v1/access_token', params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: 10000, + }); + + const data = response.data; + + this.accessToken = data.access_token; + this.refreshToken = data.refresh_token; // Cisco rotates refresh tokens + this.expiresAt = Date.now() + (data.expires_in * 1000) - (5 * 60 * 1000); // 5 min safety buffer + + await this.saveTokens(); + + logger('webex:auth', `Tokens refreshed successfully — new access token expires in ${data.expires_in} seconds`); + return this.accessToken; + + } catch (err) { + const errorDetail = err.response?.data || err.message; + logger('webex:auth', `Token refresh failed: ${errorDetail}`, 'error'); + + if (err.response?.status === 400 || err.response?.status === 401) { + throw new Error( + 'Refresh token may be invalid or revoked. ' + + 'You will need to bootstrap new tokens manually.' + ); + } + + throw err; + } + } + + /** + * Get a currently valid access token. + * Will refresh automatically if expired or near expiry. + * + * The whole "check + refresh" path runs inside a mutex so concurrent callers + * never trigger overlapping token rotations. + */ + async getAccessToken() { + // Fast path: already-valid in-memory token, no lock needed. + if (this.accessToken && Date.now() < this.expiresAt) { + return this.accessToken; + } + + return this._authMutex.runExclusive(async () => { + // Re-check inside the critical section — another caller may have + // already loaded/refreshed while we were waiting for the lock. + if (this.accessToken && Date.now() < this.expiresAt) { + return this.accessToken; + } + + // Lazy-load on first use + if (!this.accessToken && !this.refreshToken) { + try { + await this.loadTokens(); + } catch (err) { + throw new Error('Tokens not loaded and no file present – bootstrap required'); + } + } + + if (this.accessToken && Date.now() < this.expiresAt) { + return this.accessToken; + } + + logger('webex:auth', 'Access token expired or missing → refreshing...'); + return this.refresh(); + }); + } + + /** + * Force a refresh (useful for testing or recovery). + * Also serialized through the auth mutex so it can't race a normal getAccessToken(). + */ + async forceRefresh() { + logger('webex:auth', 'Forcing token refresh...', 'warn'); + return this._authMutex.runExclusive(() => this.refresh()); + } + + /** + * Clear all token state (for logout/testing) + */ + clearTokens() { + this.accessToken = null; + this.refreshToken = null; + this.expiresAt = 0; + logger('webex:auth', 'Webex token state cleared', 'warn'); + } +} + +export default WebexServiceAppAuth; \ No newline at end of file diff --git a/integrations/webex/XapiClient.js b/integrations/webex/XapiClient.js new file mode 100644 index 0000000..a5d0e57 --- /dev/null +++ b/integrations/webex/XapiClient.js @@ -0,0 +1,79 @@ +// src/integrations/webex/XapiClient.js +import webex from './WebexClient.js'; // reuse the main client for auth (xapi calls now delegate to it for consistency + retry) +import { logger } from '../../utils/logger.js'; + +class XapiClient { + static #instance = null; + + constructor() { + if (XapiClient.#instance) { + return XapiClient.#instance; + } + + XapiClient.#instance = this; + logger('webex:xapi', 'XapiClient initialized (delegates xapi to WebexClient.request)'); + } + + async xCommand(command, payload = {}) { + // Delegate to webex.request (same pattern used by vcProvision for all xapi/command/* calls). + // Benefits: automatic 401 retry + token handling + consistent error surfacing. + try { + const data = await webex.request('POST', `xapi/command/${command}`, payload); + logger('webex:xapi', `xCommand ${command} succeeded`); + return data; + } catch (err) { + const errorDetail = err.response?.data?.message || err.response?.data || err.message; + logger('webex:xapi', `xCommand ${command} failed: ${errorDetail}`, 'error'); + throw err; + } + } + + async xConfiguration(deviceId, path, value) { + logger('webex:xapi', `Setting configuration: ${path} = ${value} on device ${deviceId}`); + return this.xCommand('xConfiguration', { + deviceId, + arguments: { Path: path, Value: value } + }); + } + + /** + * Execute an xCommand on a device (e.g. Logging.ExtendedLogging.Start). + * Convenience wrapper: pass deviceId + flat arguments object. + */ + async xCommandWithDevice(commandKey, deviceId, argumentsObj = {}) { + return this.xCommand(commandKey, { + deviceId, + arguments: argumentsObj + }); + } + + /** + * Query xStatus on a device (subtree). + * Example: xStatus(deviceId, 'Logging.ExtendedLogging') + * + * Per cloud xAPI (and vcProvision xapi call patterns): + * GET /v1/xapi/status?deviceId=...&name=Logging.ExtendedLogging + * Response shape: { deviceId, result: { Logging: { ExtendedLogging: { Mode, PacketDump, ... } } } } + * Uses webex.request for 401-retry consistency with other xapi/* calls in the project. + */ + async xStatus(deviceId, name = '') { + const params = { deviceId }; + if (name) { + params.name = name; // e.g. "Logging.ExtendedLogging" or "Logging.ExtendedLogging.Mode" (dot notation) + } + + try { + const data = await webex.request('GET', 'xapi/status', null, params); + + logger('webex:xapi', `xStatus ${name || '(root)'} succeeded for device`); + return data; + } catch (err) { + const errorDetail = err.response?.data?.message || err.response?.data || err.message; + logger('webex:xapi', `xStatus ${name} failed: ${errorDetail}`, 'error'); + throw err; + } + } +} + +// Export singleton instance +export default new XapiClient(); \ No newline at end of file diff --git a/integrations/xai/client.js b/integrations/xai/client.js new file mode 100644 index 0000000..2ac2e1c --- /dev/null +++ b/integrations/xai/client.js @@ -0,0 +1,53 @@ +import axios from 'axios'; +import { logger } from '../../utils/logger.js'; + + +export async function summarizeTicketWithGrokFromContext(context, ticketId) { + const systemPrompt = ` +You are an expert HVAC/facilities technician and ServiceChannel ticket analyst. + +Summarize this ticket clearly and concisely. +Use the **ticket description** as the primary source for the **main problem / reason for the ticket**. +Use the notes to provide timeline, actions, status updates, and pending items. + +Structure your summary with these sections: +- **Main Problem** (from description) +- **Key Events & Timeline** (chronological bullets from notes, most recent last) +- **Actions Taken** +- **Current Status / Blockers** +- **Pending / Next Steps** + +Keep it professional, neutral, factual, under 250 words. +Use bullet points where helpful. +If notes are repetitive, deduplicate them. +If no notes, omit "Key Events & Timeline" or say "No notes recorded." +`; + + const userPrompt = `Summarize this ServiceChannel ticket:\n${context}`; + + try { + const response = await axios.post( + process.env.XAI_URL, + { + model: process.env.XAI_MODEL, // or your working model + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.3, + max_tokens: 500 + }, + { + headers: { + Authorization: `Bearer ${process.env.XAI_API_KEY}`, + 'Content-Type': 'application/json' + } + } + ); + + return response.data.choices[0].message.content.trim(); + } catch (err) { + logger('xai:client', `Error: ${err.response?.data || err.message}`, 'error'); + return `(Summary failed) Raw ticket info: ${context.substring(0, 200)}...`; + } +} \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..3875dd8 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,22 @@ +{ + "_comment_signal": "Use SIGTERM (which the app already handles) instead of nodemon's default SIGUSR2. This ensures every restart triggers the graceful shutdown() path in index.js, which calls framework.stop() and webex.internal.device.unregister(). Without this, each save would leak a WDM device registration and you'd eventually hit Cisco's per-bot device cap with 'Forbidden: User has excessive device registrations' on startup.", + "_comment_delay": "Wait 1s after a file change before restarting — collapses bursts of saves (e.g. format-on-save touching multiple files) into a single restart.", + "_comment_signal_timeout_workaround": "nodemon's `kill` setting controls how long it waits between sending the configured signal and SIGKILL'ing the app. 5s gives framework.stop()'s DELETE /wdm/api/v1/devices call enough time to complete on a typical RTT to Cisco.", + "signal": "SIGTERM", + "delay": 1000, + "kill": 5000, + "watch": [ + "index.js", + "commands/", + "integrations/", + "utils/" + ], + "ext": "js,json", + "ignore": [ + "node_modules/", + "public/", + "logs/", + "tokens/", + "*.test.js" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..85661ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12660 @@ +{ + "name": "collabfinder", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "collabfinder", + "version": "1.0.0", + "dependencies": { + "async-mutex": "^0.5.0", + "axios": "^1.13.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "form-data": "^4.0.5", + "graphql-request": "^7.4.0", + "node-cron": "^4.2.1", + "webex-node-bot-framework": "^2.5.1" + }, + "devDependencies": { + "nodemon": "^3.1.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", + "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/polyfill": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz", + "integrity": "sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g==", + "deprecated": "🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.", + "license": "MIT", + "dependencies": { + "core-js": "^2.6.5", + "regenerator-runtime": "^0.13.4" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs2": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.29.2.tgz", + "integrity": "sha512-+FqVkbqWaDleqS9fgzFypApKoPvmGFgk5X2lGXbL9wgz6tf88qt2HEUuEn9E3yBeLt7p8pIgODbJ5icVRALKhQ==", + "license": "MIT", + "dependencies": { + "core-js": "^2.6.12" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse--for-generate-function-map": { + "name": "@babel/traverse", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ciscospark/test-users-legacy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ciscospark/test-users-legacy/-/test-users-legacy-1.2.0.tgz", + "integrity": "sha512-WEAe6ntEZOk3TPzk7BGyattgh9OZnKPYNb9idfZnI3Dkb9iO3zUB3AlCqrAMp1I6dQ5+RTkgTuSGdgWP4Toluw==", + "license": "UNLICENSED", + "optional": true, + "dependencies": { + "btoa": "^1.1.2", + "lodash": "^4.17.4", + "node-random-name": "^1.0.1", + "request": "^2.81.0" + } + }, + "node_modules/@expo/cli": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-55.0.17.tgz", + "integrity": "sha512-/x7rWkapzuBgHIBvQeJsvuaj0AVuYCX80ypCoJbF1fPnbFexOJ2iCnuaiCz+8d5b1OzLkuOgGs/s7OaaSPWmCQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~55.0.9", + "@expo/config-plugins": "~55.0.6", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.1.1", + "@expo/image-utils": "^0.8.12", + "@expo/json-file": "^10.0.12", + "@expo/log-box": "55.0.7", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "~55.0.10", + "@expo/osascript": "^2.4.2", + "@expo/package-manager": "^1.10.3", + "@expo/plist": "^0.5.2", + "@expo/prebuild-config": "^55.0.9", + "@expo/require-utils": "^55.0.3", + "@expo/router-server": "^55.0.10", + "@expo/schema-utils": "^55.0.2", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.4.0", + "@react-native/dev-middleware": "0.83.2", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "better-opn": "~3.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.3", + "expo-server": "^55.0.6", + "fetch-nodeshim": "^0.4.6", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.0", + "multitars": "^0.2.3", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.3", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "source-map-support": "~0.5.21", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/cli/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/cli/node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "optional": true, + "peer": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/@expo/cli/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@expo/cli/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@expo/cli/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/code-signing-certificates/node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "optional": true, + "peer": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/@expo/config": { + "version": "55.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.9.tgz", + "integrity": "sha512-uYPwTnBtp7aSGhNjvdhqUfi8SodvDlIqKzWq94WcWaFBr/RzcJ/pa0TZOy2E6YgjvrcZOcSj3xRQYwWoo+9jag==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/config-plugins": "~55.0.6", + "@expo/config-types": "^55.0.5", + "@expo/json-file": "^10.0.12", + "@expo/require-utils": "^55.0.3", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/config-plugins": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.6.tgz", + "integrity": "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/config-types": "^55.0.5", + "@expo/json-file": "~10.0.12", + "@expo/plist": "^0.5.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-types": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", + "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-55.0.2.tgz", + "integrity": "sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "55.0.3", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-55.0.3.tgz", + "integrity": "sha512-bY4/rfcZ0f43DvOtMn8/kmPlmo01tex5hRoc5hKbwBwQjqWQuQt0ACwu7akR9IHI4j0WNG48eL6cZB6dZUFrzg==", + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/env": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz", + "integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/fingerprint": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.16.6.tgz", + "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/env": "^2.0.11", + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.12.tgz", + "integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/json-file": { + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.12.tgz", + "integrity": "sha512-inbDycp1rMAelAofg7h/mMzIe+Owx6F7pur3XdQ3EPTy00tme+4P6FWgHKUcjN8dBSrnbRNpSyh5/shzHyVCyQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/local-build-cache-provider": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.6.tgz", + "integrity": "sha512-4kfdv48sKzokijMqi07fINYA9/XprshmPgSLf8i69XgzIv2YdRyBbb70SzrufB7PDneFoltz8N83icW8gOOj1g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/config": "~55.0.8", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/log-box": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-55.0.7.tgz", + "integrity": "sha512-m7V1k2vlMp4NOj3fopjOg4zl/ANXyTRF3HMTMep2GZAKsPiDzgOQ41nm8CaU50/HlDIGXlCObss07gOn20UpHQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/dom-webview": "^55.0.3", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^55.0.3", + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/metro": { + "version": "54.2.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", + "integrity": "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3" + } + }, + "node_modules/@expo/metro-config": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-55.0.10.tgz", + "integrity": "sha512-3mZolGb90f0DnknjjJw8zNVjNu2m3ctiUs+DvEHmSy2K5Pag+6gLPPLHsnFJ1R0PJ6t7U+BuUp23E5jnFCDVcA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~55.0.9", + "@expo/env": "~2.1.1", + "@expo/json-file": "~10.0.12", + "@expo/metro": "~54.2.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.32.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/osascript": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", + "integrity": "sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/spawn-async": "^1.7.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.3.tgz", + "integrity": "sha512-ZuXiK/9fCrIuLjPSe1VYmfp0Sa85kCMwd8QQpgyi5ufppYKRtLBg14QOgUqj8ZMbJTxE0xqzd0XR7kOs3vAK9A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/json-file": "^10.0.12", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.2.tgz", + "integrity": "sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "55.0.9", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-55.0.9.tgz", + "integrity": "sha512-834FhfnUh5fGUguJ46MNIBVsAsC5NO0zHD8Vz8FSvG/J07f6Fdtwf9zV5YTst/GXW4uWGGJKPERVUaGmsN8sAw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/config": "~55.0.9", + "@expo/config-plugins": "~55.0.6", + "@expo/config-types": "^55.0.5", + "@expo/image-utils": "^0.8.12", + "@expo/json-file": "^10.0.12", + "@react-native/normalize-colors": "0.83.2", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/@expo/require-utils": { + "version": "55.0.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.3.tgz", + "integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/router-server": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-55.0.10.tgz", + "integrity": "sha512-NZQzHwkaedufNPayVfPxsZGEMngOD3gDvYx9lld4sitRexrKDx5sHmmNHi6IByGbmCb4jwLXub5sIyWh6z1xPQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^55.0.6", + "expo": "*", + "expo-constants": "^55.0.7", + "expo-font": "^55.0.4", + "expo-router": "*", + "expo-server": "^55.0.6", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/@expo/schema-utils": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-55.0.2.tgz", + "integrity": "sha512-QZ5WKbJOWkCrMq0/kfhV9ry8te/OaS34YgLVpG8u9y2gix96TlpRTbxM/YATjNcUR2s4fiQmPCOxkGtog4i37g==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@expo/spawn-async": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", + "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/ws-tunnel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", + "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.1.tgz", + "integrity": "sha512-KZNxZvnGCtiM2aYYZ6Wz0Ix5r47dAvpNLApFtZWnSoERzAdOMzVBOPysBoM0JlF6FKWZ8GPqgn6qt3dV/8Zlpg==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema/node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.84.1.tgz", + "integrity": "sha512-lAJ6PDZv95FdT9s9uhc9ivhikW1Zwh4j9XdXM7J2l4oUA3t37qfoBmTSDLuPyE3Bi+Xtwa11hJm0BUTT2sc/gg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.83.2", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.83.2.tgz", + "integrity": "sha512-XbcN/BEa64pVlb0Hb/E/Ph2SepjVN/FcNKrJcQvtaKZA6mBSO8pW8Eircdlr61/KBH94LihHbQoQDzkQFpeaTg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.83.2" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.83.2", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.83.2.tgz", + "integrity": "sha512-X/RAXDfe6W+om/Fw1i6htTxQXFhBJ2jgNOWx3WpI3KbjeIWbq7ib6vrpTeIAW2NUMg+K3mML1NzgD4dpZeqdjA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.83.2", + "babel-plugin-syntax-hermes-parser": "0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/babel-preset/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.83.2", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.83.2.tgz", + "integrity": "sha512-9uK6X1miCXqtL4c759l74N/XbQeneWeQVjoV7SD2CGJuW7ZefxaoYenwGPs7rMoCdtS6wuIyR3hXQ+uWEBGYXA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.32.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@react-native/codegen/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@react-native/codegen/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/@react-native/codegen/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.84.1.tgz", + "integrity": "sha512-f6a+mJEJ6Joxlt/050TqYUr7uRRbeKnz8lnpL7JajhpsgZLEbkJRjH8HY5QiLcRdUwWFtizml4V+vcO3P4RxoQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@react-native/dev-middleware": "0.84.1", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.3", + "metro-config": "^0.83.3", + "metro-core": "^0.83.3", + "semver": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-frontend": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.84.1.tgz", + "integrity": "sha512-rUU/Pyh3R5zT0WkVgB+yA6VwOp7HM5Hz4NYE97ajFS07OUIcv8JzBL3MXVdSSjLfldfqOuPEuKUaZcAOwPgabw==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-shell": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.84.1.tgz", + "integrity": "sha512-LIGhh4q4ette3yW5OzmukNMYwmINYrRGDZqKyTYc/VZyNpblZPw72coXVHXdfpPT6+YlxHqXzn3UjFZpNODGCQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/@react-native/dev-middleware": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.84.1.tgz", + "integrity": "sha512-Z83ra+Gk6ElAhH3XRrv3vwbwCPTb04sPPlNpotxcFZb5LtRQZwT91ZQEXw3GOJCVIFp9EQ/gj8AQbVvtHKOUlQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.84.1", + "@react-native/debugger-shell": "0.84.1", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@react-native/community-cli-plugin/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.83.2", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.83.2.tgz", + "integrity": "sha512-t4fYfa7xopbUF5S4+ihNEwgaq4wLZLKLY0Ms8z72lkMteVd3bOX2Foxa8E2wTfRvdhPOkSpOsTeNDmD8ON4DoQ==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.83.2", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.83.2.tgz", + "integrity": "sha512-z9go6NJMsLSDJT5MW6VGugRsZHjYvUTwxtsVc3uLt4U9W6T3J6FWI2wHpXIzd2dUkXRfAiRQ3Zi8ZQQ8fRFg9A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.83.2", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.83.2.tgz", + "integrity": "sha512-Zi4EVaAm28+icD19NN07Gh8Pqg/84QQu+jn4patfWKNkcToRFP5vPEbbp0eLOGWS+BVB1d1Fn5lvMrJsBbFcOg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.83.2", + "@react-native/debugger-shell": "0.83.2", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@react-native/dev-middleware/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.84.1.tgz", + "integrity": "sha512-7uVlPBE3uluRNRX4MW7PUJIO1LDBTpAqStKHU7LHH+GRrdZbHsWtOEAX8PiY4GFfBEvG8hEjiuTOqAxMjV+hDg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.84.1.tgz", + "integrity": "sha512-UsTe2AbUugsfyI7XIHMQq4E7xeC8a6GrYwuK+NohMMMJMxmyM3JkzIk+GB9e2il6ScEQNMJNaj+q+i5za8itxQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.83.2", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.83.2.tgz", + "integrity": "sha512-gkZAb9LoVVzNuYzzOviH7DiPTXQoZPHuiTH2+O2+VWNtOkiznjgvqpwYAhg58a5zfRq5GXlbBdf5mzRj5+3Y5Q==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.84.1.tgz", + "integrity": "sha512-sJoDunzhci8ZsqxlUiKoLut4xQeQcmbIgvDHGQKeBz6uEq9HgU+hCWOijMRr6sLP0slQVfBAza34Rq7IbXZZOA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/@unimodules/core": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@unimodules/core/-/core-7.2.0.tgz", + "integrity": "sha512-Nu+bAd/xG4B2xyYMrmV3LnDr8czUQgV1XhoL3sOOMwGydDJtfpWNodGhPhEMyKq2CXo4X7DDIo8qG6W2fk6XAQ==", + "deprecated": "replaced by the 'expo' package, learn more: https://blog.expo.dev/whats-new-in-expo-modules-infrastructure-7a7cdda81ebc", + "license": "MIT", + "optional": true, + "dependencies": { + "expo-modules-core": "~0.4.0" + } + }, + "node_modules/@unimodules/react-native-adapter": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@unimodules/react-native-adapter/-/react-native-adapter-6.5.0.tgz", + "integrity": "sha512-F2J6gVw9a57DTVTQQunp64fqD4HVBkltOpUz1L5lEccNbQlZEA7SjnqKJzXakI7uPhhN76/n+SGb7ihzHw2swQ==", + "deprecated": "replaced by the 'expo' package, learn more: https://blog.expo.dev/whats-new-in-expo-modules-infrastructure-7a7cdda81ebc", + "license": "MIT", + "optional": true, + "dependencies": { + "expo-modules-autolinking": "^0.3.2", + "expo-modules-core": "~0.4.0" + } + }, + "node_modules/@webex/common": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/common/-/common-2.60.0.tgz", + "integrity": "sha512-o6dbes91uqxLO9gjafIl46ycQVpzFmsENTldFh0uFb0LbT2ONX9DRO6599nS6Jd9xa4g+R2DoNHMJSwvj2Zjgw==", + "license": "MIT", + "dependencies": { + "backoff": "^2.5.0", + "bowser": "^2.11.0", + "core-decorators": "^0.20.0", + "global": "^4.4.0", + "lodash": "^4.17.21", + "safe-buffer": "^5.2.0", + "urlsafe-base64": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/common-timers": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/common-timers/-/common-timers-2.60.0.tgz", + "integrity": "sha512-DvxvEYGuqM80sH1y+YdB+u4Bl7dqle6FIP0FsE+N5k4bCWbred+yDy7ZmuLZ4a1SK/SSLxSBTo+VzIWk6Oithw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/helper-html": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/helper-html/-/helper-html-2.60.0.tgz", + "integrity": "sha512-qyKsajx8gNZkZnp21LasfTiKoxqaIYzYgT9XvEL0QRmQOJZWxZIzYFQQVDOa2UgULC721xTLfdnCriSL5Qfn3w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/helper-image": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/helper-image/-/helper-image-2.60.0.tgz", + "integrity": "sha512-2q0ZZyDegBeMv5DIW0VORf8WYmTNgAAv7MeRAzM+kvurBDE+yJabqoBXYj+nf5lSUjEOUnWwWtgFOELnN+YwbQ==", + "license": "MIT", + "dependencies": { + "@webex/http-core": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-file": "2.60.0", + "@webex/test-helper-mocha": "2.60.0", + "exifr": "^5.0.3", + "gm": "^1.23.1", + "lodash": "^4.17.21", + "mime": "^2.4.4", + "safe-buffer": "^5.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/http-core": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/http-core/-/http-core-2.60.0.tgz", + "integrity": "sha512-z+GHjx3d4Q0exVIfLex2iS1wYpkYscctIHiK1CPInivubpgtvt04E/qETtDV1iCo8wxhyzG2eAW/n0FcA7rdJQ==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/test-helper-test-users": "2.60.0", + "@webex/webex-core": "2.60.0", + "file-type": "^16.0.1", + "global": "^4.4.0", + "is-function": "^1.0.1", + "lodash": "^4.17.21", + "parse-headers": "^2.0.2", + "qs": "^6.7.3", + "request": "^2.88.0", + "safe-buffer": "^5.2.0", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-media-core": { + "version": "0.0.7-beta", + "resolved": "https://registry.npmjs.org/@webex/internal-media-core/-/internal-media-core-0.0.7-beta.tgz", + "integrity": "sha512-GxSRFKDdvL/gzZ67aIO2378kbVWAeGUQk5/pUprPcdY0cC4T6aoXa7AiLV+HiFmJSChPzBYZbOlFY49oSndgww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.9", + "@webex/ts-sdp": "^1.0.1", + "detectrtc": "^1.4.1", + "events": "^3.3.0", + "sdp-transform": "^2.14.1", + "typed-emitter": "^2.1.0", + "uuid": "^8.3.2", + "webrtc-adapter": "^8.1.1", + "xstate": "^4.30.6" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@webex/internal-media-core/node_modules/sdp": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz", + "integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==", + "license": "MIT" + }, + "node_modules/@webex/internal-media-core/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@webex/internal-media-core/node_modules/webrtc-adapter": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.4.tgz", + "integrity": "sha512-VwtwbYNKnVQW8koB9qb8YcxNwpSVHTvvKEZLzY6uQ3gFrA9E87VPbB5xE+m1AGwUjL1UgN35jRR9hQgteZI5bg==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/@webex/internal-plugin-calendar": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-calendar/-/internal-plugin-calendar-2.60.0.tgz", + "integrity": "sha512-xcT60Q2TKXvDJ6szQeXOAP5fygtZR6i2Ol5TOtqla3n9tpEGBSdUz8WTjVr/3A1TPkyLHRBcC1wxCKkWuuYDWQ==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-conversation": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-encryption": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-conversation": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-conversation/-/internal-plugin-conversation-2.60.0.tgz", + "integrity": "sha512-1VzUBMLcdw/dBC8BjARHsHr+guOuSgIRYksHVGa63nk0b3yQskP0jWTigYv0/G1TdBGzYB0UCrtpYgNYdd06ww==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/helper-html": "2.60.0", + "@webex/helper-image": "2.60.0", + "@webex/internal-plugin-encryption": "2.60.0", + "@webex/internal-plugin-user": "2.60.0", + "@webex/webex-core": "2.60.0", + "crypto-js": "^4.1.1", + "lodash": "^4.17.21", + "node-scr": "^0.3.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-device": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-device/-/internal-plugin-device-2.60.0.tgz", + "integrity": "sha512-FmrRARGLGvb276LQpTG5u8mKo8mGrrHG+UcXvzu2sU7+p/7Wgm+z/izshJJC55SFWF77gV8oR3sAcTYwZMnqKw==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/common-timers": "2.60.0", + "@webex/http-core": "2.60.0", + "@webex/internal-plugin-metrics": "2.60.0", + "@webex/webex-core": "2.60.0", + "ampersand-collection": "^2.0.2", + "ampersand-state": "^5.0.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-encryption": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-encryption/-/internal-plugin-encryption-2.60.0.tgz", + "integrity": "sha512-NvKJUvoRhV2b737nuYq/mWkvqD8ZOM3rCVqY+MhxXXqantHu3jqxfvZ86O3r++Mfyob201C8N0HtwzdupCM4yg==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/common-timers": "2.60.0", + "@webex/http-core": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/test-helper-file": "2.60.0", + "@webex/webex-core": "2.60.0", + "asn1js": "^2.0.26", + "debug": "^4.3.4", + "isomorphic-webcrypto": "^2.3.8", + "lodash": "^4.17.21", + "node-jose": "^2.2.0", + "node-kms": "^0.4.0", + "node-scr": "^0.3.0", + "pkijs": "^2.1.84", + "safe-buffer": "^5.2.0", + "uuid": "^3.3.2", + "valid-url": "^1.0.9" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-feature": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-feature/-/internal-plugin-feature-2.60.0.tgz", + "integrity": "sha512-ble2sAAveXbGdee1QboU3ZCoDT71iWwaGNld/5WSdEpsNKO9sV5WC5Neo3oq8SmKiYSdLoEEYFC5J+9B26Bkdg==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-locus": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-locus/-/internal-plugin-locus-2.60.0.tgz", + "integrity": "sha512-r0LSnwk+9dDMp/pGflx+c+gX1CPDo06j4dMhRbkLkJTqqhC+uAnvHREVIQuITViwY9cf+Izm9On3xh6SDqHglw==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-mock-webex": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-lyra": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-lyra/-/internal-plugin-lyra-2.60.0.tgz", + "integrity": "sha512-OSMl+sh2hvWMHw0kd+MdVeJa2coKCPlzk89+BLU51DYrgc+6DvBGOFegNNqRa+a1Z5iJtKB7Q5gI5Xq/baZmtg==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-conversation": "2.60.0", + "@webex/internal-plugin-encryption": "2.60.0", + "@webex/internal-plugin-feature": "2.60.0", + "@webex/internal-plugin-locus": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/webex-core": "2.60.0", + "bowser": "^2.11.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-mercury": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-mercury/-/internal-plugin-mercury-2.60.0.tgz", + "integrity": "sha512-TMKhtWsuC+glUDWEtiTRJTPF/1IS9MVV7zEAP1bqgVsdI9b6iV1k+p72il5Q2i+ynksdbuzg0Pd4cM28pO7hwA==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/common-timers": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-feature": "2.60.0", + "@webex/internal-plugin-metrics": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-mocha": "2.60.0", + "@webex/test-helper-mock-web-socket": "2.60.0", + "@webex/test-helper-mock-webex": "2.60.0", + "@webex/test-helper-refresh-callback": "2.60.0", + "@webex/test-helper-test-users": "2.60.0", + "@webex/webex-core": "2.60.0", + "backoff": "^2.5.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2", + "ws": "^8.2.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-metrics": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-metrics/-/internal-plugin-metrics-2.60.0.tgz", + "integrity": "sha512-AKTUnVoGtP2ib3ui5cY00f61TVRtsNwfg2QXJ0uIRHU9w1FbC0rY4M3eJ+ssOJAGtrSa1InmDO5KFdmHuvHP8g==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/common-timers": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-mock-webex": "2.60.0", + "@webex/webex-core": "2.60.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-presence": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-presence/-/internal-plugin-presence-2.60.0.tgz", + "integrity": "sha512-3dd+JdGFvVUrPugDD2KhhhzjHGsg/lhXOsndPf7kZNPq9a1si0EX70R3ig4RyXostsmunRtNp9DaSlBKmg8J6g==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-mocha": "2.60.0", + "@webex/test-helper-mock-webex": "2.60.0", + "@webex/test-helper-test-users": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-search": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-search/-/internal-plugin-search-2.60.0.tgz", + "integrity": "sha512-e4lXYHPdl1Qa+lA5ojjhWZWT1avakjGUK42OU5j9kQfi1IQ/sbdpyAlISwjTvm0qJ2G8ImcQJUwddxaGv991nA==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-conversation": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-encryption": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-support": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-support/-/internal-plugin-support-2.60.0.tgz", + "integrity": "sha512-eiiH8Pr/HaCvhdQy66mmIqES8/096xyBYWCLvxleWJ32PfJ/TFZcDJbaC+odSKb3dCQyqBcx4QsNa1JFLYRW+w==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-search": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-file": "2.60.0", + "@webex/test-helper-mock-webex": "2.60.0", + "@webex/test-helper-test-users": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/internal-plugin-user": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/internal-plugin-user/-/internal-plugin-user-2.60.0.tgz", + "integrity": "sha512-kl6IP4NxQ3EJI8CCIF4lY5OJM6M+sTaUadFdvMlZqOaG1zNbXIH9BuClB3xXdJVlO9Cmkbew1iuRabASODFoKg==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-mock-webex": "2.60.0", + "@webex/test-helper-test-users": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-attachment-actions": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-attachment-actions/-/plugin-attachment-actions-2.60.0.tgz", + "integrity": "sha512-qeR+B/RwXpCIVB967NmSEKBzqhvB6jN8FyYI5MySNZ76KG/nsH9Ys9zsWi4I7ilj8AsRVuxc7+YwY/B1wMXHIA==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-conversation": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-messages": "2.60.0", + "@webex/plugin-people": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-test-users": "2.60.0", + "@webex/webex-core": "2.60.0", + "debug": "^4.3.4", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-authorization": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-authorization/-/plugin-authorization-2.60.0.tgz", + "integrity": "sha512-WqijIyG1Fy4LOOa31hcTtzzbK9xM2eFOG+2YBbPxPCsjJBGeS2PTyt/c3kvmaufTpFKbB8NGYF96PWL03NIgrQ==", + "license": "MIT", + "dependencies": { + "@webex/plugin-authorization-browser": "2.60.0", + "@webex/plugin-authorization-node": "2.60.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-authorization-browser": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-authorization-browser/-/plugin-authorization-browser-2.60.0.tgz", + "integrity": "sha512-OdG6o0w/kUiJPPGPZR0PYWmEAJxESN3YcgiLFCsT1/LU2iZjtwQ3Hk8SxtGw4ueG7zxlsIKI9uHYKLIDlflkAg==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/plugin-authorization-node": "2.60.0", + "@webex/storage-adapter-local-storage": "2.60.0", + "@webex/storage-adapter-spec": "2.60.0", + "@webex/webex-core": "2.60.0", + "jose": "^4.13.1", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-authorization-node": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-authorization-node/-/plugin-authorization-node-2.60.0.tgz", + "integrity": "sha512-Kd8EnG3eQ8fdogjr0S/wCGPsY8rY37nM2kFuPtzv6d8YjzUcNMaW/3yhLMr2BbOx3o9JKptQHUNx5q7dm1CnnA==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/webex-core": "2.60.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-device-manager": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-device-manager/-/plugin-device-manager-2.60.0.tgz", + "integrity": "sha512-Q3GpsS9RLx5KUfU8I+9QXbD2OVY5aEdkXDRW80IYavFgm//vWynw5/yNtHF/Er1JyhHJY2jJepk2bsoMoyNoug==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-calendar": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-lyra": "2.60.0", + "@webex/internal-plugin-search": "2.60.0", + "@webex/plugin-authorization": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-logger": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-logger/-/plugin-logger-2.60.0.tgz", + "integrity": "sha512-0FtUyW3T53tO7DBkSRZpKfugR2nSE1nklg4p1JWZIGRASAjCCBmShE0J0eedTO9zhiWtNuABdRUph5HfxxM32A==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-mocha": "2.60.0", + "@webex/test-helper-mock-webex": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-meetings": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-meetings/-/plugin-meetings-2.60.0.tgz", + "integrity": "sha512-DjUNzL4wni1Q5UcyaO66gXOLnfF2e69zulwxGPAUuvfjUQ2kn08gWymKE0YNw0PwbvM5RjMF5matYQI7NvhlnA==", + "license": "Cisco EULA (https://www.cisco.com/c/en/us/products/end-user-license-agreement.html)", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-media-core": "0.0.7-beta", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-metrics": "2.60.0", + "@webex/internal-plugin-support": "2.60.0", + "@webex/internal-plugin-user": "2.60.0", + "@webex/plugin-people": "2.60.0", + "@webex/ts-sdp": "1.0.1", + "@webex/webex-core": "2.60.0", + "bowser": "^2.11.0", + "btoa": "^1.2.1", + "dotenv": "^4.0.0", + "global": "^4.4.0", + "ip-anonymize": "^0.1.0", + "javascript-state-machine": "^3.1.0", + "lodash": "^4.17.21", + "sdp-transform": "^2.12.0", + "uuid": "^3.3.2", + "webrtc-adapter": "^7.7.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-meetings/node_modules/dotenv": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-4.0.0.tgz", + "integrity": "sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.6.0" + } + }, + "node_modules/@webex/plugin-memberships": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-memberships/-/plugin-memberships-2.60.0.tgz", + "integrity": "sha512-b0Vmxtx++wkSjg/BpmUYGFcTqs+5ULxgG3YVHf6ttB0Fsr4U1AGHGwXoVNDIgxnpqP9S23lcDPgAYL47EKdsNA==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-conversation": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-messages": "2.60.0", + "@webex/plugin-people": "2.60.0", + "@webex/plugin-rooms": "2.60.0", + "@webex/webex-core": "2.60.0", + "debug": "^4.3.4", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-messages": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-messages/-/plugin-messages-2.60.0.tgz", + "integrity": "sha512-iZI5edGSUc7CAheMtkUCZHnqRs5oB3DKKznkcRfob3dY6uemop1wEv6PsWYdrHAu3Aage74cJ5p7XcAkHHxLsw==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-conversation": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-people": "2.60.0", + "@webex/plugin-rooms": "2.60.0", + "@webex/webex-core": "2.60.0", + "debug": "^4.3.4", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-people": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-people/-/plugin-people-2.60.0.tgz", + "integrity": "sha512-nr+FuF4mnV8vfAQeqzJguu5c7rHjEd0G86f9dyS0EHdZoHr41ggNK1QDcxuguZHSB5fdkn7tLqC5vpKdj8fmMA==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/webex-core": "2.60.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-rooms": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-rooms/-/plugin-rooms-2.60.0.tgz", + "integrity": "sha512-nv5UAomq1NWpO8beoLP6YOd5l5Tj7CO2ShJ/F0d7t6StOIUmoJZtEV8KGytaEwmxB2mI/ppXrU6Sb0SrNGoY/g==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/internal-plugin-conversation": "2.60.0", + "@webex/internal-plugin-mercury": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-memberships": "2.60.0", + "@webex/plugin-messages": "2.60.0", + "@webex/plugin-people": "2.60.0", + "@webex/webex-core": "2.60.0", + "debug": "^4.3.4", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-team-memberships": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-team-memberships/-/plugin-team-memberships-2.60.0.tgz", + "integrity": "sha512-XLfH7NZvuOOqsZ5ghNGAxoAlDaCRap1uOqCCpDhJkDdM57S9jAKmdYReXH6ucJun4KNdV05h0sDOM5NGvbxzqg==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-device": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-rooms": "2.60.0", + "@webex/plugin-teams": "2.60.0", + "@webex/webex-core": "2.60.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-teams": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-teams/-/plugin-teams-2.60.0.tgz", + "integrity": "sha512-tLQD56T393OROG2rLJAaaVZVmy0tZeMNJkJTQypwO3d8u/anMHeypgTqINrpD/B+QB7sO+F5IsP3HT+70MVbQg==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-device": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-memberships": "2.60.0", + "@webex/plugin-rooms": "2.60.0", + "@webex/test-helper-chai": "2.60.0", + "@webex/test-helper-test-users": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/plugin-webhooks": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/plugin-webhooks/-/plugin-webhooks-2.60.0.tgz", + "integrity": "sha512-NCiiXPudHh6X+MkGnRAumFgqadMGQ5t6k8h+DE1lGME3ujVp7dO0L+ujbiA8K3ylywk8f6AN0vRim3wvN4wPDQ==", + "license": "MIT", + "dependencies": { + "@webex/internal-plugin-device": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-rooms": "2.60.0", + "@webex/webex-core": "2.60.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/storage-adapter-local-storage": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/storage-adapter-local-storage/-/storage-adapter-local-storage-2.60.0.tgz", + "integrity": "sha512-1L48cIY0IyEzR2pqVhJAFwjBKP7ZiY/Dfs9xeFQ25l9d2Qa1n4uFBorA9v7JYSjpU61/APIIdUuciHfEWFvtOQ==", + "license": "MIT", + "dependencies": { + "@webex/storage-adapter-spec": "2.60.0", + "@webex/test-helper-mocha": "2.60.0", + "@webex/webex-core": "2.60.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/storage-adapter-spec": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/storage-adapter-spec/-/storage-adapter-spec-2.60.0.tgz", + "integrity": "sha512-ovd2YB85qEPnKWjoKj9OcZlefhdWBSlDCD6TSD31zM3kPJ8P85yIG8Cg9z7fSi5eB7zKHRIJii8Kh1M90Au72w==", + "license": "MIT", + "dependencies": { + "@webex/test-helper-chai": "2.60.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-chai": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-chai/-/test-helper-chai-2.60.0.tgz", + "integrity": "sha512-PM75a1rqP7O8IE/1IwosSL4UsND6bNClZzFNi8PGnW11BTCdzjlOz9zbUGiVtB5IO218CZ2KUAum6w2cWjHp0A==", + "license": "MIT", + "dependencies": { + "@webex/test-helper-file": "2.60.0", + "check-error": "^1.0.2", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-file": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-file/-/test-helper-file-2.60.0.tgz", + "integrity": "sha512-YVH+s3qqU0KWWDqu2kWsDbVX1cq11WB5j5H/x6+o8v19mbVO5ttqwiMaBEHBZ0t3xz0eo7EUfsL1QUlI/VcJJQ==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/test-helper-make-local-url": "2.60.0", + "es6-promise": "^4.2.8", + "file-type": "^16.0.1", + "xhr": "^2.5.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-make-local-url": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-make-local-url/-/test-helper-make-local-url-2.60.0.tgz", + "integrity": "sha512-uwk9tlrGaHqTi24F/Cpp4fUJKqAAH3+AiQ+onFbZ4+KbuRG7SamDrRdU0DK/3j7r80gLeuwqc1WKglmcZLfwnw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-mocha": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-mocha/-/test-helper-mocha-2.60.0.tgz", + "integrity": "sha512-d8tq9LC9TRiY6I9+TAhSRUm5WMB+7rKqj2NYp80BGnUJ1IJUSji6r9ePUbhoLQsRtESFGc5Pkz0PmDQjMi5U5Q==", + "license": "MIT", + "dependencies": { + "bowser": "^2.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-mock-web-socket": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-mock-web-socket/-/test-helper-mock-web-socket-2.60.0.tgz", + "integrity": "sha512-J4MZmqq1ZMtb2Gb4nBMFrh+DmTXFVl8dsqUxiD2UqIxO1sfzNvhZK7EcWUsppsp/04k+wUmBQr0zaHWIAKvEQQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-mock-webex": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-mock-webex/-/test-helper-mock-webex-2.60.0.tgz", + "integrity": "sha512-syDORTuinBRPpiBIdl+fUuUWDqB1hdctRfoTX3T7hFSK+/LNPFKJSyjQ01D7jEdMPWoqELjkBPDyf9wVI03PbA==", + "license": "MIT", + "dependencies": { + "ampersand-state": "^5.0.3", + "es6-promise": "^4.2.8", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-refresh-callback": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-refresh-callback/-/test-helper-refresh-callback-2.60.0.tgz", + "integrity": "sha512-jNHdT2rME3/xFtgJwMux+W7HIo7GX8Q7W0O41Ua8tco0/hpSRkBL7jQ8EQxcj4E1UkQyn629LCqCpPO7RlqVXA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-retry": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-retry/-/test-helper-retry-2.60.0.tgz", + "integrity": "sha512-e8+LJTuvEBkgXf3SxHPA1Tv+wD7StwGBX43BUdSwVjMOPEI7FLQ5d2uJZty/FqpYu7fcmv9iHSU2Tv0uFe0Jmw==", + "license": "MIT", + "dependencies": { + "es6-promise": "^4.2.8" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/test-helper-test-users": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-helper-test-users/-/test-helper-test-users-2.60.0.tgz", + "integrity": "sha512-Duv2AJz1Pi8PN/QCt7kALxLRV57uiKpPPJYtJklphdt/VSWbkRCuEWlu6bX4XAgVxQE46WTISJ1Zox303MyD+A==", + "license": "MIT", + "dependencies": { + "@webex/test-helper-retry": "2.60.0", + "@webex/test-users": "2.60.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@ciscospark/test-users-legacy": "^1.0.2" + } + }, + "node_modules/@webex/test-users": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/test-users/-/test-users-2.60.0.tgz", + "integrity": "sha512-8lE97PC0uePV/QQ8L2D2mr+kIcNLB8H9TWgMhPZlCYAk9DaAJNQcVMYLQLkjYoD/e7kPcPpmuHIID/3q2Xjedg==", + "license": "MIT", + "dependencies": { + "@webex/http-core": "2.60.0", + "@webex/test-helper-mocha": "2.60.0", + "btoa": "^1.2.1", + "lodash": "^4.17.21", + "node-random-name": "^1.0.1", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@webex/ts-sdp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@webex/ts-sdp/-/ts-sdp-1.0.1.tgz", + "integrity": "sha512-dRbsF/MIS2bnnnbUMQL92SUZT3v9dhLJw2ItzGxqs9xaiVfEKVbjM020Hbd4ACQZ8dJ49CZ2tDd4Se9xUR3anQ==", + "license": "ISC" + }, + "node_modules/@webex/webex-core": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/@webex/webex-core/-/webex-core-2.60.0.tgz", + "integrity": "sha512-Tr18TdcsIsfD1TgJqF1O+BdshvNcDsFdNiHlCpVo5GhIrssOfxQr46sbLh0pCrGS9pDa4xx9YMgSEmqfFLY+6w==", + "license": "MIT", + "dependencies": { + "@webex/common": "2.60.0", + "@webex/common-timers": "2.60.0", + "@webex/http-core": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/storage-adapter-spec": "2.60.0", + "ampersand-collection": "^2.0.2", + "ampersand-events": "^2.0.2", + "ampersand-state": "^5.0.3", + "core-decorators": "^0.20.0", + "crypto-js": "^4.1.1", + "jsonwebtoken": "^9.0.0", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alea": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/alea/-/alea-0.0.9.tgz", + "integrity": "sha512-7GrAOnIHGlKtOmZm09dHL+n5tXlao4uBGeXUPRX+I5PAyZqa95CaSFC9bXpkFJpT6j5N3+UKoxDfPmmwBedg7A==", + "license": "MIT" + }, + "node_modules/ampersand-class-extend": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ampersand-class-extend/-/ampersand-class-extend-2.0.0.tgz", + "integrity": "sha512-i8hQvA4vZz9UfQAi0A4oBASYOZzlYgjFVkw0K1xpeKNSvq+KYkFOqJKkNvHCbbuKUNJnFk3kECSKPDAJ6ocEOg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.11.1" + } + }, + "node_modules/ampersand-collection": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ampersand-collection/-/ampersand-collection-2.0.2.tgz", + "integrity": "sha512-IjDa4HTL/tdQDDL0SGyWk4AHD02iNtUSLRWkAsJ2biPvapljW9HNgIEIdbPnnR+7Gb9BJkjesaLNjVZfAMzeuA==", + "license": "MIT", + "dependencies": { + "ampersand-class-extend": "^2.0.0", + "ampersand-events": "^2.0.1", + "ampersand-version": "^1.0.2", + "lodash": "^4.11.1" + } + }, + "node_modules/ampersand-events": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ampersand-events/-/ampersand-events-2.0.2.tgz", + "integrity": "sha512-pPnVEJviRxXi9YhZA9j3GwGGBTlDLi+YIoBvrpKXgce+CO1nMlZU2aOV8OJogNuR2YPbptAUHNz7SKX+MvLj8A==", + "license": "MIT", + "dependencies": { + "ampersand-version": "^1.0.2", + "lodash": "^4.6.1" + } + }, + "node_modules/ampersand-state": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/ampersand-state/-/ampersand-state-5.0.3.tgz", + "integrity": "sha512-sr904K5zvw6mkGjFHhTcfBIdpoJ6mn/HrFg7OleRmBpw3apLb3Z0gVrgRTb7kK1wOLI34vs4S+IXqNHUeqWCzw==", + "license": "MIT", + "dependencies": { + "ampersand-events": "^2.0.1", + "ampersand-version": "^1.0.0", + "array-next": "~0.0.1", + "key-tree-store": "^1.3.0", + "lodash": "^4.12.0" + } + }, + "node_modules/ampersand-version": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ampersand-version/-/ampersand-version-1.0.2.tgz", + "integrity": "sha512-FVVLY7Pghtgc8pQl0rF3A3+OS/CZ+/ILLMIYIaO1cA9v5SRkainqUMfSot3fu32svuThIsYK3q9iCsH9W5+mWQ==", + "license": "MIT", + "dependencies": { + "find-root": "^0.1.1", + "through2": "^0.6.3" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0", + "optional": true, + "peer": true + }, + "node_modules/array-next": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/array-next/-/array-next-0.0.1.tgz", + "integrity": "sha512-sBOC/Iaz2hCcYi2XlyRfyZCRUxamlE5NJXEFjE9BTx23HALnWAFsPjGtfrAclt9o3G/38Het2yyeyOd3CEY7lg==", + "license": "MIT" + }, + "node_modules/array-parallel": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", + "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==", + "license": "MIT" + }, + "node_modules/array-series": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", + "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/asmcrypto.js": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-0.22.0.tgz", + "integrity": "sha512-usgMoyXjMbx/ZPdzTSXExhMPur2FTdz/Vo5PVx2gIaBcdAAJNOFlsdgqveM8Cff7W0v+xrf9BwjOV26JSAF9qA==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/asn1js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-2.4.0.tgz", + "integrity": "sha512-PvZC0FMyMut8aOnR2jAEGSkmRtHIUYPe9amUEnGjr9TdnUmsfoOkjrvUkOEU9mzpYBR1HyO9bF+8U1cLTMMHhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvutils": "^1.1.3" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/b64-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/b64-lite/-/b64-lite-1.4.0.tgz", + "integrity": "sha512-aHe97M7DXt+dkpa8fHlCcm1CnskAHrJqEfMI0KN7dwqlzml/aUe1AGt6lk51HzrSfVD67xOso84sOpr+0wIe2w==", + "license": "MIT", + "dependencies": { + "base-64": "^0.1.0" + } + }, + "node_modules/b64u-lite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/b64u-lite/-/b64u-lite-1.1.0.tgz", + "integrity": "sha512-929qWGDVCRph7gQVTC6koHqQIpF4vtVaSbwLltFQo44B1bYUquALswZdBKFfrJCPEnsCOvWkJsPdQYZ/Ukhw8A==", + "license": "MIT", + "dependencies": { + "b64-lite": "^1.4.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.1.tgz", + "integrity": "sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-parser": "0.32.1" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-expo": { + "version": "55.0.11", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-55.0.11.tgz", + "integrity": "sha512-ti8t4xufD6gUQQh+qY+b+VT/1zyA0n1PBnwOzCkPUyEDiIVBpaOixR+BzVH68hqu9mH2wDfzoFuGgv+2LfRdqw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.83.2", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^55.0.4", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/bson": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", + "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-1.1.3.tgz", + "integrity": "sha512-JDGoiJ+yt+4Ui1e/vMWx5TRvmnErBBbsOkprXgbe1fRp2XZzI8MoknoiR/ZVCya9aWJbOhrJ5Heon1wrAdftkg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "optional": true, + "peer": true + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "license": "MIT", + "optional": true + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/connect/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/connect/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-decorators": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/core-decorators/-/core-decorators-0.20.0.tgz", + "integrity": "sha512-7cp/Pz3AmQXjRwhAsFN+8ndRiBNyLxtZgC/fhKvrwQTf2ZlZma6LnimoJPrOqgxZ0tIeI9VvSs+QKe0OPJ0SuA==", + "license": "MIT" + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detectrtc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/detectrtc/-/detectrtc-1.4.1.tgz", + "integrity": "sha512-lxvyNN6/dSnwoVj1VstVFHel7S0BTmkfv1+01IBEy42D20pue27eB/MfphUOQz78jJ7WcQJDo6ZybhgBlUDi0Q==", + "license": "MIT" + }, + "node_modules/dnssd-advertise": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.3.tgz", + "integrity": "sha512-XENsHi3MBzWOCAXif3yZvU1Ah0l+nhJj1sjWL6TnOAYKvGiFhbTx32xHN7+wLMLUOCj7Nr0evADWG4R8JtqCDA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exifr": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-5.0.6.tgz", + "integrity": "sha512-iDB4IhKoKVF+uDDrHRlyNxWqGaTxYluVWqvBWVG54HkQZe8qkFYl9eQrjEP3d8Q4UMBZ9rWu3Pa+mfC+o4CZuw==", + "license": "MIT" + }, + "node_modules/expo": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/expo/-/expo-55.0.7.tgz", + "integrity": "sha512-0k4wVQGRjWvxdKeZlmGUSIcntG/alOXfGqpsyGAp16y6ds5oeOe9dezARs8GbLS7U4cE9wjkT8T9z0uxyMoDuw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "55.0.17", + "@expo/config": "~55.0.9", + "@expo/config-plugins": "~55.0.6", + "@expo/devtools": "55.0.2", + "@expo/fingerprint": "0.16.6", + "@expo/local-build-cache-provider": "55.0.6", + "@expo/log-box": "55.0.7", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "55.0.10", + "@expo/vector-icons": "^15.0.2", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~55.0.11", + "expo-asset": "~55.0.9", + "expo-constants": "~55.0.8", + "expo-file-system": "~55.0.11", + "expo-font": "~55.0.4", + "expo-keep-awake": "~55.0.4", + "expo-modules-autolinking": "55.0.10", + "expo-modules-core": "55.0.16", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.1" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-native": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-asset": { + "version": "55.0.9", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-55.0.9.tgz", + "integrity": "sha512-cBZy8uG6eNyHnCCVIQaJsYYCxaQEWctq6xDjxJ8Hm03GTJkZF4OGCGgq2+OboBUGeOmJd7qlvMpXeXY5giMmOQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/image-utils": "^0.8.12", + "expo-constants": "~55.0.8" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.8.tgz", + "integrity": "sha512-fhB+8EePHyHu2fVHFRObKV7QL4RCQc6OfJyNn34f6/KEoA3e0q/iCL24IUW2RoIYq+sdNHl09MjDU0Hhh1Ec4A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/config": "~55.0.9", + "@expo/env": "~2.1.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-file-system": { + "version": "55.0.11", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.11.tgz", + "integrity": "sha512-KMUd6OY375J9WD79ZvjvCDZMveT7YfgiGWdi58/gfuTBsr14TRuoPk8RRQHAtc4UquzWViKcHwna9aPY7/XPpw==", + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-55.0.4.tgz", + "integrity": "sha512-ZKeGTFffPygvY5dM/9ATM2p7QDkhsaHopH7wFAWgP2lKzqUMS9B/RxCvw5CaObr9Ro7x9YptyeRKX2HmgmMfrg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-keep-awake": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", + "integrity": "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg==", + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-0.3.4.tgz", + "integrity": "sha512-Mu3CIMqEAI8aNM18U/l+7CCi+afU8dERrKjDDEx/Hu7XX3v3FcnnP+NuWDLY/e9/ETzwTJaqoRoBuzhawsuLWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chalk": "^4.1.0", + "commander": "^7.2.0", + "fast-glob": "^3.2.5", + "find-up": "~5.0.0", + "fs-extra": "^9.1.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-0.4.10.tgz", + "integrity": "sha512-uCZA3QzF0syRaHwYY99iaNhnye4vSQGsJ/y6IAiesXdbeVahWibX4G1KoKNPUyNsKXIM4tqA+4yByUSvJe4AAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "compare-versions": "^3.4.0", + "invariant": "^2.2.4" + } + }, + "node_modules/expo-random": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/expo-random/-/expo-random-14.0.1.tgz", + "integrity": "sha512-gX2mtR9o+WelX21YizXUCD/y+a4ZL+RDthDmFkHxaYbdzjSYTn8u/igoje/l3WEO+/RYspmqUFa8w/ckNbt6Vg==", + "deprecated": "This package is now deprecated in favor of expo-crypto, which provides the same functionality. To migrate, replace all imports from expo-random with imports from expo-crypto.", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-server": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz", + "integrity": "sha512-xI72FTm469FfuuBL2R5aNtthgH+GR7ygOpsx/KcPS0K8AZaZd7VjtEExbzn9/qyyYkWW3T+3dAmCDKOMX8gdmQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo/node_modules/expo-modules-autolinking": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-55.0.10.tgz", + "integrity": "sha512-qBnoohB7YblKvFCPAwi2JvS2r7SKET8sSKDD1sihRTNEGEge579+NXM1HBa0oYYfCqOXByxWai86XrWeTYd03w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@expo/require-utils": "^55.0.3", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo/node_modules/expo-modules-core": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-55.0.16.tgz", + "integrity": "sha512-VeZxwnyHM4Kf52FqonPwlkYdF6YQnZAIXWT3RpEv2Ng/mwheZ2PZGc3rxaHAo0FoeSXM9i0g3HCJjOJUkZwV3A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "optional": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "optional": true, + "peer": true, + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-nodeshim": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.9.tgz", + "integrity": "sha512-XIQWlB2A4RZ7NebXWGxS0uDMdvRHkiUDTghBVJKFg9yEOd45w/PP8cZANuPf2H08W6Cor3+2n7Q6TTZgAS3Fkw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-root": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-0.1.2.tgz", + "integrity": "sha512-GyDxVgA61TZcrgDJPqOqGBpi80Uf2yIstubgizi7AjC9yPdRrqBR+Y0MvK4kXnYlaoz3d+SGxDHMYVkwI/yd2w==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "optional": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "license": "MIT", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "node_modules/gm": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/gm/-/gm-1.25.1.tgz", + "integrity": "sha512-jgcs2vKir9hFogGhXIfs0ODhJTfIrbECCehg38tqFgHm8zqXx7kAJyCYAFK4jTjx71AxrkFtkJBawbAxYUPX9A==", + "deprecated": "The gm module has been sunset. Please migrate to an alternative. https://github.com/aheckmann/gm?tab=readme-ov-file#2025-02-24-this-project-is-not-maintained", + "license": "MIT", + "dependencies": { + "array-parallel": "~0.1.3", + "array-series": "~0.1.5", + "cross-spawn": "^7.0.5", + "debug": "^3.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gm/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-request": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.4.0.tgz", + "integrity": "sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "peerDependencies": { + "graphql": "14 - 16" + } + }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-compiler": { + "version": "250829098.0.9", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.9.tgz", + "integrity": "sha512-hZ5O7PDz1vQ99TS7HD3FJ9zVynfU1y+VWId6U1Pldvd8hmAYrNec/XLPYJKD3dLOW6NXak6aAQAuMuSo3ji0tQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/hermes-estree": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.1.tgz", + "integrity": "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/hermes-parser": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.1.tgz", + "integrity": "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-estree": "0.32.1" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "optional": true, + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ip-anonymize": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ip-anonymize/-/ip-anonymize-0.1.0.tgz", + "integrity": "sha512-cZJu+N5JKKFGMK0eEQWNaQMn2EhCysciVM6eotCJwfqotj16BTfVchKsJCH6mQAT9N0GC7oWRcsZ6Lb8dDiwTA==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "license": "MIT" + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isomorphic-webcrypto": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/isomorphic-webcrypto/-/isomorphic-webcrypto-2.3.8.tgz", + "integrity": "sha512-XddQSI0WYlSCjxtm1AI8kWQOulf7hAN3k3DclF1sxDJZqOe0pcsOt675zvWW91cZH9hYs3nlA3Ev8QK5i80SxQ==", + "license": "MIT", + "dependencies": { + "@peculiar/webcrypto": "^1.0.22", + "asmcrypto.js": "^0.22.0", + "b64-lite": "^1.3.1", + "b64u-lite": "^1.0.1", + "msrcrypto": "^1.5.6", + "str2buf": "^1.3.0", + "webcrypto-shim": "^0.1.4" + }, + "optionalDependencies": { + "@unimodules/core": "*", + "@unimodules/react-native-adapter": "*", + "expo-random": "*", + "react-native-securerandom": "^0.1.1" + } + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/javascript-state-machine": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/javascript-state-machine/-/javascript-state-machine-3.1.0.tgz", + "integrity": "sha512-BwhYxQ1OPenBPXC735RgfB+ZUG8H3kjsx8hrYTgWnoy6TPipEy4fiicyhT2lxRKAXq9pG7CfFT8a2HLr6Hmwxg==", + "license": "MIT" + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT", + "optional": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD", + "optional": true, + "peer": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "optional": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/key-tree-store": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/key-tree-store/-/key-tree-store-1.3.0.tgz", + "integrity": "sha512-qXk+lR+LXvGos3wqMxIMWweKDgCx8ZKWM6BEPm7iZkOKug5ggi66vUt+3vbtKJLBrAyOxQ4S8JRwK++Q4XZRmw==", + "license": "MIT" + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.0.tgz", + "integrity": "sha512-EZgbsXMrGS+oK+Ta12mCjzBFse+SIewGdwrSTr5g+MSymnjpox2x05ceI20PQejJOFvOgzcXrfDk/SdY7dSCtw==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "optional": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash._arraycopy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz", + "integrity": "sha512-RHShTDnPKP7aWxlvXKiDT6IX2jCs6YZLCtNhOru/OX2Q/tzX295vVBK5oX1ECtN+2r86S0Ogy8ykP1sgCZAN0A==", + "license": "MIT" + }, + "node_modules/lodash._arrayeach": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz", + "integrity": "sha512-Mn7HidOVcl3mkQtbPsuKR0Fj0N6Q6DQB77CtYncZcJc0bx5qv2q4Gl6a0LC1AN+GSxpnBDNnK3CKEm9XNA4zqQ==", + "license": "MIT" + }, + "node_modules/lodash._baseassign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", + "integrity": "sha512-t3N26QR2IdSN+gqSy9Ds9pBu/J1EAFEshKlUHpJG3rvyJOYgcELIxcIeKKfZk7sjOz11cFfzJRsyFry/JyabJQ==", + "license": "MIT", + "dependencies": { + "lodash._basecopy": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "node_modules/lodash._baseclone": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lodash._baseclone/-/lodash._baseclone-3.3.0.tgz", + "integrity": "sha512-1K0dntf2dFQ5my0WoGKkduewR6+pTNaqX03kvs45y7G5bzl4B3kTR4hDfJIc2aCQDeLyQHhS280tc814m1QC1Q==", + "license": "MIT", + "dependencies": { + "lodash._arraycopy": "^3.0.0", + "lodash._arrayeach": "^3.0.0", + "lodash._baseassign": "^3.0.0", + "lodash._basefor": "^3.0.0", + "lodash.isarray": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "node_modules/lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha512-rFR6Vpm4HeCK1WPGvjZSJ+7yik8d8PVUdCJx5rT2pogG4Ve/2ZS7kfmO5l5T2o5V2mqlNIfSF5MZlr1+xOoYQQ==", + "license": "MIT" + }, + "node_modules/lodash._basefor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash._basefor/-/lodash._basefor-3.0.3.tgz", + "integrity": "sha512-6bc3b8grkpMgDcVJv9JYZAk/mHgcqMljzm7OsbmcE2FGUMmmLQTPHlh/dFqR8LA0GQ7z4K67JSotVKu5058v1A==", + "license": "MIT" + }, + "node_modules/lodash._bindcallback": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz", + "integrity": "sha512-2wlI0JRAGX8WEf4Gm1p/mv/SZ+jLijpj0jyaE/AXeuQphzCgD8ZQW4oSpoN8JAopujOFGU3KMuq7qfHBWlGpjQ==", + "license": "MIT" + }, + "node_modules/lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha512-RrL9VxMEPyDMHOd9uFbvMe8X55X16/cGM5IgOKgRElQZutpX89iS6vwl64duTV1/16w5JY7tuFNXqoekmh1EmA==", + "license": "MIT" + }, + "node_modules/lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha512-De+ZbrMu6eThFti/CSzhRvTKMgQToLxbij58LMfM8JnYDNSOjkjTCIaa8ixglOeGh2nyPlakbt5bJWJ7gvpYlQ==", + "license": "MIT" + }, + "node_modules/lodash.clone": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-3.0.3.tgz", + "integrity": "sha512-yVYPpFTdZDCLG2p07gVRTvcwN5X04oj2hu4gG6r0fer58JA08wAVxXzWM+CmmxO2bzOH8u8BkZTZqgX6juVF7A==", + "deprecated": "This package is deprecated. Use structuredClone instead.", + "license": "MIT", + "dependencies": { + "lodash._baseclone": "^3.0.0", + "lodash._bindcallback": "^3.0.0", + "lodash._isiterateecall": "^3.0.0" + } + }, + "node_modules/lodash.clonedeep": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-3.0.2.tgz", + "integrity": "sha512-I8MpGh5z+6OixDAAb21teLSZDmqVPjlq02Q7ZFrbn2xnQHYYuJf6on/94SWpF/p0s3p/cEv/53ro4AhDOfCR0g==", + "license": "MIT", + "dependencies": { + "lodash._baseclone": "^3.0.0", + "lodash._bindcallback": "^3.0.0" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha512-CuBsapFjcubOGMn3VD+24HOAPxM79tH+V6ivJL3CHYjtrawauDJHUk//Yew9Hvc6e9rbCrURGk8z6PC+8WJBfQ==", + "license": "MIT", + "dependencies": { + "lodash._getnative": "^3.0.0", + "lodash.isarguments": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT", + "optional": true + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/metro": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", + "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "@babel/types": "^7.25.2", + "accepts": "^1.3.7", + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.32.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3", + "mime-types": "^2.1.27", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", + "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.32.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro-cache": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", + "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache-key": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", + "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/metro-cache/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/metro-config": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", + "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.3", + "metro-cache": "0.83.3", + "metro-core": "0.83.3", + "metro-runtime": "0.83.3", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-core": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", + "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-file-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", + "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", + "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-resolver": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", + "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-runtime": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", + "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-source-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", + "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/traverse": "^7.25.3", + "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.3", + "nullthrows": "^1.1.1", + "ob1": "0.83.3", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", + "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.3", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", + "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", + "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-source-map": "0.83.3", + "metro-transform-plugins": "0.83.3", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/min-document": { + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "devOptional": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/mongodb": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz", + "integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==", + "license": "Apache-2.0", + "dependencies": { + "bl": "^2.2.1", + "bson": "^1.1.4", + "denque": "^1.4.1", + "optional-require": "^1.1.8", + "safe-buffer": "^5.1.2" + }, + "engines": { + "node": ">=4" + }, + "optionalDependencies": { + "saslprep": "^1.0.0" + }, + "peerDependenciesMeta": { + "aws4": { + "optional": true + }, + "bson-ext": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "mongodb-extjson": { + "optional": true + }, + "snappy": { + "optional": true + } + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msrcrypto": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/msrcrypto/-/msrcrypto-1.5.8.tgz", + "integrity": "sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q==", + "license": "Apache-2.0" + }, + "node_modules/multitars": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-0.2.4.tgz", + "integrity": "sha512-XgLbg1HHchFauMCQPRwMj6MSyDd5koPlTA1hM3rUFkeXzGpjU/I9fP3to7yrObE9jcN8ChIOQGrM0tV0kUZaKg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-jose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-2.2.0.tgz", + "integrity": "sha512-XPCvJRr94SjLrSIm4pbYHKLEaOsDvJCpyFw/6V/KK/IXmyZ6SFBzAUDO9HQf4DB/nTEFcRGH87mNciOP23kFjw==", + "license": "Apache-2.0", + "dependencies": { + "base64url": "^3.0.1", + "buffer": "^6.0.3", + "es6-promise": "^4.2.8", + "lodash": "^4.17.21", + "long": "^5.2.0", + "node-forge": "^1.2.1", + "pako": "^2.0.4", + "process": "^0.11.10", + "uuid": "^9.0.0" + } + }, + "node_modules/node-jose/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/node-kms": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/node-kms/-/node-kms-0.4.1.tgz", + "integrity": "sha512-qhrfrsEPooJCTDPc8yPaLIu8rHGKrSngK/X9eZziM0RUnMY3PtKU8PHmG5JokeNw7wokW8/dKtwEH5I24oW5ew==", + "license": "Apache-2.0", + "dependencies": { + "es6-promise": "^2.0.1", + "lodash.clone": "^3.0.2", + "lodash.clonedeep": "^3.0.1", + "node-jose": "^2.2.0", + "uuid": "^2.0.1" + } + }, + "node_modules/node-kms/node_modules/es6-promise": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-2.3.0.tgz", + "integrity": "sha512-oyOjMhyKMLEjOOtvkwg0G4pAzLQ9WdbbeX7WdqKzvYXu+UFgD0Zo/Brq5Q49zNmnGPPzV5rmYvrr0jz1zWx8Iw==", + "license": "MIT" + }, + "node_modules/node-kms/node_modules/uuid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "integrity": "sha512-FULf7fayPdpASncVy4DLh3xydlXEJJpvIELjYjNeQWYUZ9pclcpvCZSr2gkmN2FrrGcI7G/cJsIEwk5/8vfXpg==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT" + }, + "node_modules/node-random-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-random-name/-/node-random-name-1.0.1.tgz", + "integrity": "sha512-7+IpyBRtbHvTWXjdZxjxyaafdggIvA3IpNf2W3ZRe+ok3UyE32Qb8PCb4fKKPdZCCjoAk358yQZWywAimw8KCw==", + "license": "MIT", + "dependencies": { + "alea": "0.0.9" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-scr": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/node-scr/-/node-scr-0.3.0.tgz", + "integrity": "sha512-Hb0ykojynSbt7ra6eml6NX39WAumFfU3G81XvLpp2H7y8KjQc29oEIf2TlgZQCfA+pyxbY5t4a1xBqPpyrbpvw==", + "license": "Apache-2.0", + "dependencies": { + "es6-promise": "^2.0.1", + "lodash.clone": "^3.0.2", + "node-jose": "^2.0.0" + } + }, + "node_modules/node-scr/node_modules/es6-promise": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-2.3.0.tgz", + "integrity": "sha512-oyOjMhyKMLEjOOtvkwg0G4pAzLQ9WdbbeX7WdqKzvYXu+UFgD0Zo/Brq5Q49zNmnGPPzV5rmYvrr0jz1zWx8Iw==", + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/ob1": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", + "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optional-require": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.10.tgz", + "integrity": "sha512-0r3OB9EIQsP+a5HVATHq2ExIy2q/Vaffoo4IAikW1spCYswhLxqWQS0i3GwS3AdY/OIP4SWZHLGz8CMU558PGw==", + "license": "Apache-2.0", + "dependencies": { + "require-at": "^1.0.6" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "optional": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkijs": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-2.4.0.tgz", + "integrity": "sha512-cjJP/mYuGyMrjJ49jI04khId5Oufd3nFTUYBzQTIIVNI7/oAWdwXEfpwTF8HELFV/gz+WGYUBHCe3KHWD8rYvg==", + "license": "BSD-3-Clause", + "dependencies": { + "asn1js": "^3.0.3", + "bytestreamjs": "^1.0.29", + "pvutils": "^1.1.3" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pkijs/node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/react-native": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.84.1.tgz", + "integrity": "sha512-0PjxOyXRu3tZ8EobabxSukvhKje2HJbsZikR0U+pvS0pYZza2hXKjcSBiBdFN4h9D0S3v6a8kkrDK6WTRKMwzg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/assets-registry": "0.84.1", + "@react-native/codegen": "0.84.1", + "@react-native/community-cli-plugin": "0.84.1", + "@react-native/gradle-plugin": "0.84.1", + "@react-native/js-polyfills": "0.84.1", + "@react-native/normalize-colors": "0.84.1", + "@react-native/virtualized-lists": "0.84.1", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-jest": "^29.7.0", + "babel-plugin-syntax-hermes-parser": "0.32.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.9", + "invariant": "^2.2.4", + "jest-environment-node": "^29.7.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.83.3", + "metro-source-map": "^0.83.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-securerandom": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/react-native-securerandom/-/react-native-securerandom-0.1.1.tgz", + "integrity": "sha512-CozcCx0lpBLevxiXEb86kwLRalBCHNjiGPlw3P7Fi27U6ZLdfjOCNRHD1LtBKcvPvI3TvkBXB3GOtLvqaYJLGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "*" + }, + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/react-native/node_modules/@react-native/codegen": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.84.1.tgz", + "integrity": "sha512-n1RIU0QAavgCg1uC5+s53arL7/mpM+16IBhJ3nCFSd/iK5tUmCwxQDcIDC703fuXfpub/ZygeSjVN8bcOWn0gA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "hermes-parser": "0.32.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/react-native/node_modules/@react-native/normalize-colors": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.84.1.tgz", + "integrity": "sha512-/UPaQ4jl95soXnLDEJ6Cs6lnRXhwbxtT4KbZz+AFDees7prMV2NOLcHfCnzmTabf5Y3oxENMVBL666n4GMLcTA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/react-native/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/require-at": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz", + "integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==", + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "optional": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rtcpeerconnection-shim": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz", + "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^2.6.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saslprep": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", + "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", + "license": "MIT", + "optional": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/sdp": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz", + "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slugify": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz", + "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/str2buf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/str2buf/-/str2buf-1.3.0.tgz", + "integrity": "sha512-xIBmHIUHYZDP4HyoXGHYNVmxlXLXDrtFHYT0eV6IOdEj3VO9ccaF1Ejl9Oq8iFjITllpT8FhaXb4KsNmw+3EuA==", + "license": "MIT" + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "optional": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/through2/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urlsafe-base64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz", + "integrity": "sha512-RtuPeMy7c1UrHwproMZN9gN6kiZ0SvJwRaEzwZY0j9MypEkFqyBaKv176jvlPtg58Zh36bOkS0NFABXMHvvGCA==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, + "node_modules/webcrypto-core/node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/webcrypto-shim": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/webcrypto-shim/-/webcrypto-shim-0.1.7.tgz", + "integrity": "sha512-JAvAQR5mRNRxZW2jKigWMjCMkjSdmP5cColRP1U/pTg69VgHXEi1orv5vVpJ55Zc5MIaPc1aaurzd9pjv2bveg==", + "license": "MIT" + }, + "node_modules/webex": { + "version": "2.60.0", + "resolved": "https://registry.npmjs.org/webex/-/webex-2.60.0.tgz", + "integrity": "sha512-lE/OTtxS9YG1jOVQFdFf8mcCRupyFfCHaLDMeJa32KdIi5oZAY5viYxiRnqpoQSpy3s66VbL8v1Dcv1TCiaDzg==", + "license": "Cisco EULA (https://www.cisco.com/c/en/us/products/end-user-license-agreement.html)", + "dependencies": { + "@babel/polyfill": "^7.12.1", + "@babel/runtime-corejs2": "^7.14.8", + "@webex/common": "2.60.0", + "@webex/internal-plugin-calendar": "2.60.0", + "@webex/internal-plugin-device": "2.60.0", + "@webex/internal-plugin-presence": "2.60.0", + "@webex/internal-plugin-support": "2.60.0", + "@webex/plugin-attachment-actions": "2.60.0", + "@webex/plugin-authorization": "2.60.0", + "@webex/plugin-device-manager": "2.60.0", + "@webex/plugin-logger": "2.60.0", + "@webex/plugin-meetings": "2.60.0", + "@webex/plugin-memberships": "2.60.0", + "@webex/plugin-messages": "2.60.0", + "@webex/plugin-people": "2.60.0", + "@webex/plugin-rooms": "2.60.0", + "@webex/plugin-team-memberships": "2.60.0", + "@webex/plugin-teams": "2.60.0", + "@webex/plugin-webhooks": "2.60.0", + "@webex/storage-adapter-local-storage": "2.60.0", + "@webex/webex-core": "2.60.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webex-node-bot-framework": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/webex-node-bot-framework/-/webex-node-bot-framework-2.5.1.tgz", + "integrity": "sha512-aJ2KZMRsm+HpQ6oGZSJee678KY/p8VpRh+rsy+RFMj9xPdL1I4OOZ/xWjGOcXgwIqyiXg6tUn0nYrUf83T4JoQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eventemitter2": "^6.4.9", + "https-proxy-agent": "^5.0.1", + "lodash": "4.17.21", + "moment": "^2.29.4", + "mongodb": "^3.5.7", + "validator": "^13.7.0", + "webex": "2.60.0", + "when": "^3.7.8" + }, + "engines": { + "npm": ">=8.3.0" + } + }, + "node_modules/webrtc-adapter": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz", + "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==", + "license": "BSD-3-Clause", + "dependencies": { + "rtcpeerconnection-shim": "^1.2.15", + "sdp": "^2.12.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/whatwg-url-minimum": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.1.tgz", + "integrity": "sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/when": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/when/-/when-3.7.8.tgz", + "integrity": "sha512-5cZ7mecD3eYcMiCH4wtRPA5iFJZ50BJYDfckI5RRpQiktMiYTcn0ccLTZOvcbBume+1304fQztxeNzNS9Gvrnw==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xcode/node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==", + "license": "MIT", + "dependencies": { + "global": "~4.4.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xstate": { + "version": "4.38.3", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.38.3.tgz", + "integrity": "sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d051df0 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "collabfinder", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + "build": "echo 'No build step needed for Node.js'", + "docker:build": "docker compose build", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:logs": "docker compose logs -f" + }, + "dependencies": { + "async-mutex": "^0.5.0", + "axios": "^1.13.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "form-data": "^4.0.5", + "graphql-request": "^7.4.0", + "node-cron": "^4.2.1", + "webex-node-bot-framework": "^2.5.1" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } +} diff --git a/public/av-store-dashboard.html b/public/av-store-dashboard.html new file mode 100644 index 0000000..a9a68d6 --- /dev/null +++ b/public/av-store-dashboard.html @@ -0,0 +1,913 @@ + + + + + + AV Store Dashboard + + + + + + + + +
+

AV Store Dashboard

+

Enter a store number to load the AV topology (Meraki linkLayer infra pruned to only nodes with AV attachments + AV device nodes as connected leaves). Click any node (AV or infra switch/AP) in the diagram for details. Node colors: red = offline, orange = problem/alerting. No separate card list at top — everything is in the interactive topology.

+ + +
+
+ + +
+
+ + +
+ + + +
+ + + + \ No newline at end of file diff --git a/public/phone-store-dashboard.html b/public/phone-store-dashboard.html new file mode 100644 index 0000000..09e1336 --- /dev/null +++ b/public/phone-store-dashboard.html @@ -0,0 +1,607 @@ + + + + + + Phone Store Dashboard + + + + + + + +
+

Phone Store Dashboard

+

Enter a store number to load Webex phones + DECT (basestations/handsets) with Meraki attachments. Topology is the primary view; click any node (infra, phone, base, or handset) for rich modal details. Mirrors the AV dashboard structure.

+ + +
+
+ + +
+
+ + +
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/public/templates/av-device-modal.js b/public/templates/av-device-modal.js new file mode 100644 index 0000000..1792a5e --- /dev/null +++ b/public/templates/av-device-modal.js @@ -0,0 +1,742 @@ +import { simpleTimeAgo } from '../utils/simple-time-ago.js'; + +function showDeviceModal(identifier, deviceType, fullData = {}) { + let modal = document.getElementById('avDeviceModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'avDeviceModal'; + modal.className = 'hidden fixed inset-0 bg-black/80 flex items-center justify-center z-[100]'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + modal.querySelector('#modalClose').addEventListener('click', () => modal.classList.add('hidden')); + } + + const titleEl = modal.querySelector('#modalTitle'); + const contentEl = modal.querySelector('#modalContent'); + + const isSwitch = deviceType === 'switch' || fullData?.isInfra || /^SW/i.test(String(identifier)); + titleEl.innerHTML = isSwitch + ? `Switch / Infra: ${identifier}` + : `Device: ${identifier}`; + contentEl.innerHTML = getModalHTML(identifier, deviceType, fullData); + + modal.classList.remove('hidden'); +} + +function getModalHTML(identifier, deviceType, data) { + const isInfra = deviceType === 'switch' || data?.isInfra || /^SW/i.test(String(identifier)); + if (isInfra) { + return renderInfraSwitchModal(identifier, data || {}); + } + + const isAtlasAmp = !!data.isAtlasAmp || data.source === 'atlas'; + const isWireless = data.meraki?.connectionType === 'Wireless' || !!data.meraki?.client?.ssid; + + let html = ''; + + // MDM Section + if (data.source === 'mdm' && data.mdmData) { + html += renderMDMSection(data.mdmData); + } + + // OptiSigns + if (data.optisigns) { + html += renderOptiSignsTopBlock(data); + } + + // RED + if (data.red) { + html += renderREDSection(data.red); + } + + // Atlas AMP + if (isAtlasAmp) { + html += renderAMPTopBlock(data); + } + + // ====================== MERAKI GROUPED SECTION ====================== + let merakiContent = ''; + + // Always show the Meraki Client Information section (even if no client data) + merakiContent += renderMerakiClientSection(data); // ← pass full device, not just client + + // Wired devices: Show Switchport Status + Config + // Support both top-level (some shapes) and under .client (rich/build path where attach puts ports on client) + const portStatus = data.meraki?.portStatus || data.meraki?.client?.switchportStatus; + const portConfig = data.meraki?.portConfig || data.meraki?.client?.switchportConfig; + if (portStatus || portConfig) { + if (portStatus) { + merakiContent += renderSwitchportStatusSection(portStatus, portConfig); + } + if (portConfig) { + merakiContent += renderSwitchportConfigSection(portConfig); + } + } + // Wireless devices: Show Wireless Details + // Support top-level (rich) or under client + else if (data.meraki?.connectionType === 'Wireless' || + data.meraki?.client?.ssid || + data.meraki?.wirelessDetails || + data.meraki?.client?.wirelessDetails) { + merakiContent += renderWirelessClientSection(data); + } + // No Meraki data at all (common for some wired MSC players) + else { + // Optional: You can add a small "No switchport data" message here if desired + } + + html += sectionCard('Meraki Information', merakiContent); + + return `
${html}
`; +} + +// ====================== CONSISTENT SECTION HELPER ====================== +function sectionCard(title, content) { + return ` +
+

${title}

+ ${content} +
+ `; +} + +// ====================== RED SECTION ====================== +function renderREDSection(red) { + if (!red) return ''; + + const parseUTCDate = (ts) => { + if (!ts) return null; + let str = String(ts).trim(); + if (!/[Z+-]/.test(str)) str += 'Z'; + const date = new Date(str); + return isNaN(date.getTime()) ? null : date; + }; + + const lastPing = parseUTCDate(red.LastPingTimeUTC) ? simpleTimeAgo(parseUTCDate(red.LastPingTimeUTC)) : '—'; + const lastStartup = parseUTCDate(red.LastStartupDateUTC || red.LastStartupDate) ? simpleTimeAgo(parseUTCDate(red.LastStartupDateUTC || red.LastStartupDate)) : '—'; + + const isOnline = red.Connectivity === 'Online'; + + const statusBadge = ` + + + + + + ${red.Connectivity || 'Unknown'} + `; + + const content = ` +
+

RED Audio Player

+ ${statusBadge} +
+ +
+
+ DEVICE ID + ${red.DeviceID || '—'} +
+
+ NAME + ${red.Name || '—'} +
+
+ DEPLOYMENT + ${red.DeploymentStatusName || '—'} +
+
+ AVAILABILITY + ${red.AvailabilityStatus || '—'} +
+ +
+ LAST PING + ${lastPing} +
+
+ LAST STARTUP + ${lastStartup} +
+
+ VERSION + ${red.CurrentNanopointVersionString || '—'} +
+
+ STATE + ${red.StateTransitionStatus || '—'} +
+
+ `; + + return sectionCard('', content); // Title handled inside +} + +// ====================== OPTISIGNS SECTION ====================== +function renderOptiSignsTopBlock(data) { + const opti = data.optisigns || {}; + if (!opti) return ''; + + const playlistName = opti.currentPlaylistName || opti.currentPlaylistId || '—'; + const assetName = opti.currentAssetName || '—'; + const hasContent = !!(opti.currentPlaylistId || opti.currentAssetId); + const isRecent = opti.lastHeartBeat && (Date.now() - new Date(opti.lastHeartBeat).getTime()) < 24 * 60 * 60 * 1000; + const isActive = hasContent && isRecent; + + const lastHeartbeat = opti.lastHeartBeat ? simpleTimeAgo(new Date(opti.lastHeartBeat)) : '—'; + + const statusBadge = ` + + + + + + ${isActive ? 'Active' : 'Inactive'} + `; + + const content = ` +
+

OptiSigns

+ ${statusBadge} +
+ +
"${playlistName}"
+ ${assetName !== '—' ? `
Asset: ${assetName}
` : ''} + +
+ Last Heartbeat: + ${lastHeartbeat} +
+ `; + + return sectionCard('', content); +} + +// ====================== AMP SECTION ====================== +function renderAMPTopBlock(data) { + let atlas = {}; + if (Array.isArray(data.atlasData) && data.atlasData.length > 0) atlas = data.atlasData[0]; + else if (data.atlasData && typeof data.atlasData === 'object') atlas = data.atlasData; + else if (data.atlas) atlas = data.atlas; + + const state = atlas.state || {}; + const model = atlas.model || {}; + const firmware = atlas.firmware || {}; + + // Align status extraction with /avstatus command for consistency: + // prefer top-level status (from raw Atlas data) then state.status + const ampStatus = atlas.status || state.status || 'Unknown'; + const isOnline = (ampStatus).toLowerCase() === 'online'; + + const cpuTemp = state.tempCpu ? Number(state.tempCpu).toFixed(1) + '°F' : '—'; + const psuTemp = state.tempPsu ? Number(state.tempPsu).toFixed(1) + '°F' : '—'; + const ioTemp = state.tempIo ? Number(state.tempIo).toFixed(1) + '°F' : '—'; + const fanSpeed = state.fanSpeed ? Math.round(state.fanSpeed) + '%' : '—'; + + let ampHtml = ''; + for (let i = 1; i <= 8; i++) { + const status = state[`ampStatus_${i}`] || '—'; + const temp = state[`tempAmp_${i}`] ? Number(state[`tempAmp_${i}`]).toFixed(1) + '°F' : '—'; + ampHtml += ` +
+
AMP ${i}
+
+ ${status} +
+
${temp}
+
+ `; + } + + const content = ` +
+
+
Atlas IED Power Amplifier
+
${atlas.name || 'US002477AMP'}
+
+ + + + + + ${ampStatus} + +
+ +
+
Model
${model.name || 'AZMP8'}
+
Serial
${atlas.sn || '—'}
+
Firmware
${firmware.version || '4.5.16'}
+
IP
${state.IpAddress || '—'}
+
CPU Usage
${state.cpuUsage || '—'}%
+
RAM Usage
${state.ramUsage ? state.ramUsage.toFixed(1) : '—'}%
+
Voltage
${state.voltageMonitor || '120'}V
+
Last Seen
${simpleTimeAgo(atlas.last_seen_at)}
+
+ +
+
CPU Temp
${cpuTemp}
+
PSU Temp
${psuTemp}
+
Io Temp
${ioTemp}
+
Fan Speed
${fanSpeed}
+
+ +
+ Last Log Entry
+ ${state.lastLogEntry || '—'} +
+ +
+
Amplifier Channels
+
${ampHtml}
+
+ `; + + return sectionCard('Atlas IED Power Amplifier', content); +} + +/** + * Renders the Meraki Client Information section + * Handles both cases gracefully: has Meraki data OR no Meraki client (common for wired MSC on local switches) + */ +function renderMerakiClientSection(device) { + const meraki = device.meraki || {}; + const client = meraki.client || null; + const connectionType = meraki.connectionType || 'Unknown'; + + let isOnline = client?.status === 'Online' || false; + // Fallback using lastSeen recency if the Meraki client record lacks an explicit status (or for the most-recent picked record) + if (!isOnline && client?.lastSeen) { + const d = new Date(client.lastSeen); + if (!isNaN(d.getTime())) { + const ageMins = (Date.now() - d.getTime()) / 60000; + if (ageMins < 5) isOnline = true; + // else leave false (will show Offline badge) + } + } + const lastSeen = client?.lastSeen ? simpleTimeAgo(new Date(client.lastSeen)) : '—'; + + const cleanMac = client?.mac + ? client.mac.toUpperCase().replace(/:/g, '') + : '—'; + + const ip = client?.ip || '—'; + + // Data usage in MB (3 decimal places) + let sentMB = 0, recvMB = 0, totalMB = 0; + if (client?.usage) { + sentMB = (client.usage.sent / 1048576).toFixed(3); + recvMB = (client.usage.recv / 1048576).toFixed(3); + totalMB = (client.usage.total / 1048576).toFixed(3); + } + + const statusBadge = ` + + + + + + ${isOnline ? 'Online' : 'Offline'} + + `; + + const merakiLink = client?.id && device.meraki?.clientUrl + ? ` + View in Meraki → + ` + : ''; + + let content = ''; + + if (client) { + // Has Meraki client data + content = ` +
+

Meraki Client Information

+
+ ${statusBadge} + ${merakiLink} +
+
+ +
+
+ CONNECTION TYPE + ${connectionType} +
+
+ MAC ADDRESS + ${cleanMac} +
+
+ IP ADDRESS + ${ip} +
+
+ LAST SEEN + ${lastSeen} +
+
+ + +
+
DATA USAGE (SINCE LAST SEEN)
+
+
+
SENT
+
${sentMB} MB
+
+
+
RECEIVED
+
${recvMB} MB
+
+
+
TOTAL
+
${totalMB} MB
+
+
+
+ `; + } else { + // No Meraki client found (common for wired MSC on local switches) + content = ` +
+

Meraki Client Information

+ ${merakiLink} +
+ +
+
⚠️ No Meraki Client Data
+

This device is likely connected to a local or non-Meraki switch/infrastructure.

+

Connection Type: ${connectionType}

+
+ `; + } + + return sectionCard('', content); +} + +// ====================== MDM SECTION ====================== +function renderMDMSection(mdmData) { + if (!mdmData) return ''; + + const summary = mdmData.mdmDataSummary || {}; + const lastSeen = mdmData.LastSeen || summary.lastSeen; + + // MDM Console Link + const deviceId = mdmData.Id?.Value || summary.id || ''; + const mdmLink = deviceId + ? ` + View in MDM Console → + ` + : ''; + + const content = ` +
+

MDM Device Information

+ ${mdmLink} +
+ +
+
+ Username + ${mdmData.UserName || summary.username || '—'} +
+
+ Serial Number + ${mdmData.SerialNumber || summary.serialNumber || '—'} +
+
+ MAC Address + ${mdmData.MacAddress || summary.macAddress || '—'} +
+
+ Model + ${mdmData.Model || summary.model || 'Apple TV'} +
+ +
+ OS Version + ${mdmData.OperatingSystem || summary.osVersion || '—'} +
+
+ Compliance + + ${mdmData.ComplianceStatus || summary.complianceStatus || '—'} + +
+
+ Enrollment + ${mdmData.EnrollmentStatus || summary.enrollmentStatus || '—'} +
+
+ Location Group + ${mdmData.LocationGroupName || summary.locationGroupName || '—'} +
+ +
+ Last Seen + ${lastSeen ? simpleTimeAgo(lastSeen) : '—'} +
+
+ `; + + return sectionCard('', content); // Title moved inside for link alignment +} + +// ====================== WIRELESS SECTION ====================== +function renderWirelessClientSection(data) { + const client = data.meraki?.client || {}; + const wirelessDetails = data.meraki?.wirelessDetails || {}; + const wirelessSummary = data.meraki?.wirelessSummary || {}; + + const rssi = wirelessSummary.rssi || wirelessDetails.signalQuality?.rssi || '—'; + const snr = wirelessSummary.snr || wirelessDetails.signalQuality?.snr || '—'; + const avgLatency = wirelessSummary.avgLatencyMs || wirelessDetails.latency?.avgLatencyMs || '—'; + + const health = wirelessDetails.healthScores || {}; + const connStats = wirelessDetails.connectionStats || {}; + const failed = wirelessDetails.failedConnections?.length || 0; + + const content = ` +
+
SSID
${wirelessSummary.ssid || client.ssid || '—'}
+
Access Point
${wirelessSummary.apName || '—'}
+
RSSI
${rssi} dBm
+
SNR
${snr} dB
+
Avg Latency
${avgLatency} ms
+
+ +
+
Health Scores
+
+
+
Performance
+
${health.performance?.latest ?? '—'}
+
+
+
Onboarding
+
${health.onboarding?.latest ?? '—'}
+
+
+
+ +
+
Connection Stats
+
+
+
${connStats.assoc ?? 0}
+
Assoc Fail
+
+
+
${connStats.auth ?? 0}
+
Auth Fail
+
+
+
${connStats.dhcp ?? 0}
+
DHCP Fail
+
+
+
${connStats.dns ?? 0}
+
DNS Fail
+
+
+
${connStats.success ?? 0}
+
Success
+
+
+
+ +
+
Failed Connections
+
+ ${failed} +
+
+ `; + + return sectionCard('Wireless Details', content); + +} + +// ====================== SWITCHPORT STATUS ====================== +function renderSwitchportStatusSection(portStatus, portConfig) { + const enabledText = portStatus?.enabled !== false ? 'Enabled' : 'Disabled'; + const enabledColor = enabledText === 'Enabled' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'; + + const statusText = portStatus?.status || 'Unknown'; + const isConnected = statusText.toLowerCase() === 'connected' || statusText.toLowerCase() === 'online'; + const statusColor = isConnected ? 'bg-green-600 text-white' : 'bg-red-600 text-white'; + + const speedDuplex = `${portStatus?.speed || '—'} / ${portStatus?.duplex || '—'}`; + const poeAllocated = portStatus?.poe?.isAllocated ? 'Yes' : 'No'; + + const usageKB = portStatus?.usageInKb?.total ? portStatus.usageInKb.total.toLocaleString() : '—'; + const trafficKbps = portStatus?.trafficInKbps?.total ? portStatus.trafficInKbps.total.toFixed(1) : '—'; + + const content = ` +
+
+
${enabledText}
+
+ + ${statusText} +
+
+ +
+
Port ID
${portStatus?.portId || '—'}
+
Is Uplink
${portStatus?.isUplink ? 'Yes' : 'No'}
+
Speed / Duplex
${speedDuplex}
+
PoE Allocated
${poeAllocated}
+
Errors
${portStatus?.errors || 0}
+
Warnings
${portStatus?.warnings || 0}
+
+ +
+
+
Usage
+
${usageKB} KB
+
+
+
Traffic
+
${trafficKbps} Kbps
+
+
+
Power Usage
+
${portStatus?.powerUsageInWh || '—'} Wh
+
+
+
+ `; + + return sectionCard('Switchport Status', content); +} + +// ====================== SWITCHPORT CONFIG ====================== +function renderSwitchportConfigSection(portConfig) { + const enabledText = portConfig?.enabled !== false ? 'Enabled' : 'Disabled'; + const enabledColor = enabledText === 'Enabled' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'; + + const poeText = portConfig?.poeEnabled ? 'PoE Enabled' : 'PoE Disabled'; + const poeColor = portConfig?.poeEnabled ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300'; + + const stickyCount = (portConfig?.stickyMacAllowList || []).length; + + const content = ` +
+
+
${enabledText}
+
${poeText}
+
+ +
+
Port ID
${portConfig?.portId || '—'}
+
Name
${portConfig?.name || '—'}
+
Type
${portConfig?.type || 'Access'}
+
VLAN
${portConfig?.vlan || '—'}
+
Voice VLAN
${portConfig?.voiceVlan || 'None'}
+
Access Policy
${portConfig?.accessPolicyType || '—'}
+
Sticky MAC Limit
${portConfig?.stickyMacAllowListLimit || '—'}
+
Sticky MAC List
${stickyCount} entries
+
+
+ `; + + return sectionCard('Switchport Configuration', content); +} + +// ====================== INFRA / SWITCH MODAL (for topology infra nodes like SW02477R) ====================== +function renderInfraSwitchModal(identifier, data) { + const tNode = data.tNode || {}; + const dev = (tNode.device || data.meraki?.device || data || {}); + const serial = dev.serial || identifier || '—'; + const name = dev.name || 'Switch / Access Point'; + const model = dev.model || dev.productType || '—'; + const mStatus = (data.status || dev.status || 'Unknown').toString(); + const isOnline = /online/i.test(mStatus); + const statusBadge = ` + + + + + + ${mStatus || 'Unknown'} + `; + + const connected = Array.isArray(data.connectedAVs) ? data.connectedAVs : []; + let connectedHtml = ''; + if (connected.length) { + connectedHtml = ` +
+
Connected AV Devices (${connected.length})
+
+ ${connected.map(av => { + const c = av.meraki?.client || av.meraki || {}; + const ip = c.ip || av.ip || '—'; + const ago = c.lastSeen ? (typeof simpleTimeAgo === 'function' ? simpleTimeAgo(new Date(c.lastSeen)) : c.lastSeen) : '—'; + return ` +
+
${av.identifier || 'AV'}
+
${av.source || '—'} • ${ip} • Last seen ${ago}
+ ${c.switchport || c.port ? `
Port ${c.switchport || c.port} ${c.vlan ? 'VLAN ' + c.vlan : ''}
` : ''} +
`; + }).join('')} +
+
Click the green AV leaf nodes in the diagram for their full MDM/RED/Opti/Atlas/Meraki modals.
+
`; + } else { + connectedHtml = `
No AV devices currently attached in the enriched data.
`; + } + + const merakiLink = data.deviceUrl + ? ` + View in Meraki → + ` + : ''; + + const content = ` +
+
+
Meraki Infrastructure Device
+
${name}
+
+
+ ${statusBadge} + ${merakiLink} +
+
+ +
+
SERIAL${serial}
+
MODEL${model}
+
TYPE${tNode.type || (model && model.toUpperCase().startsWith('MR') ? 'AP' : 'Switch')}
+
LAST REPORTED${dev.lastReportedAt ? (typeof simpleTimeAgo === "function" ? simpleTimeAgo(new Date(dev.lastReportedAt)) : dev.lastReportedAt) : (dev.lastSeen ? (typeof simpleTimeAgo === "function" ? simpleTimeAgo(new Date(dev.lastSeen)) : dev.lastSeen) : "—")}
+
+ + ${connectedHtml} + +
+ This is a switch/AP-level modal (no AV source data like RED/Opti/Atlas/MDM). Use the diagram AV leaves for player details. Port-level info for attached AVs is shown above when available from Meraki client attachment. +
+ `; + + return sectionCard('', content); +} + +export { showDeviceModal }; \ No newline at end of file diff --git a/public/templates/phone-device-modal.js b/public/templates/phone-device-modal.js new file mode 100644 index 0000000..01e3a5e --- /dev/null +++ b/public/templates/phone-device-modal.js @@ -0,0 +1,379 @@ +import { simpleTimeAgo } from '../utils/simple-time-ago.js'; + +function showPhoneModal(identifier, deviceType, fullData = {}) { + let modal = document.getElementById('phoneDeviceModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'phoneDeviceModal'; + modal.className = 'hidden fixed inset-0 bg-black/80 flex items-center justify-center z-[100]'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + modal.querySelector('#modalClose').addEventListener('click', () => modal.classList.add('hidden')); + } + + const titleEl = modal.querySelector('#modalTitle'); + const contentEl = modal.querySelector('#modalContent'); + + const isDect = deviceType === 'dect-base' || deviceType === 'dect-handset' || fullData.isDect; + titleEl.innerHTML = isDect + ? `DECT: ${identifier}` + : `Phone: ${identifier}`; + contentEl.innerHTML = getPhoneModalHTML(identifier, deviceType, fullData); + + modal.classList.remove('hidden'); +} + +function getPhoneModalHTML(identifier, deviceType, data) { + let html = ''; + + const isDectBase = deviceType === 'dect-base' || data.isDectBase; + const isDectHandset = deviceType === 'dect-handset' || data.isDectHandset; + const isPhone = !isDectBase && !isDectHandset; + + if (isPhone) { + html += renderPhoneSection(data); + } else if (isDectBase) { + html += renderDectBaseSection(data); + } else if (isDectHandset) { + html += renderDectHandsetSection(data); + } + + // Always show Meraki if present + if (data.meraki) { + html += renderPhoneMerakiSection(data); + } + + // Profile / location info if top level + if (data.telephonyProfile || data.person || data.dectNetwork || data.locationMainNumber) { + html += renderPhoneContextSection(data); + } + + return `
${html}
`; +} + +function sectionCard(title, content) { + return ` +
+

${title}

+ ${content} +
+ `; +} + +function renderPhoneSection(phone) { + const lastSeen = phone.lastSeen ? simpleTimeAgo(new Date(phone.lastSeen)) : '—'; + const statusBadge = ` + + ${phone.status || 'Unknown'} + `; + + const content = ` +
+
+
Webex Desk Phone
+
${phone.displayName || phone.name || 'Unknown'}
+
+ ${statusBadge} +
+ +
+
MODEL${phone.model || phone.product || '—'}
+
SERIAL${phone.serial || '—'}
+
FIRMWARE${phone.firmware || '—'}
+
IP${phone.ipAddress || phone.meraki?.ip || '—'}
+
LAST SEEN${lastSeen}
+
MAC${phone.mac || '—'}
+
SIP URL${phone.primarySipUrl || '—'}
+
ERRORS${phone.errorCodes?.length || 0}
+
+ `; + + return sectionCard('', content); +} + +function renderDectBaseSection(base) { + const lastSeen = base.lastSeen ? simpleTimeAgo(new Date(base.lastSeen)) : '—'; + const statusBadge = ` + + ${base.status || 'Unknown'} + `; + + const content = ` +
+
+
DECT Basestation
+
${base.name || 'Unknown Base'}
+
+ ${statusBadge} +
+ +
+
MAC${base.mac || '—'}
+
FIRMWARE${base.firmware || '—'}
+
MODEL${base.model || '—'}
+
IP${base.ipAddress || '—'}
+
LAST SEEN${lastSeen}
+
LINES${base.linesRegistered || 0}
+
+ `; + + return sectionCard('', content); +} + +function renderDectHandsetSection(handset) { + const lastReg = handset.lastRegistrationTime ? simpleTimeAgo(new Date(handset.lastRegistrationTime)) : '—'; + const content = ` +
+
DECT Handset
+
${handset.name || 'Unknown Handset'}
+
+ +
+
MAC${handset.mac || '—'}
+
FIRMWARE${handset.firmware || '—'}
+
EXTENSION${handset.extension || '—'}
+
LAST REG${lastReg}
+
BASE ID${handset.baseStationId || '—'}
+
+ `; + + return sectionCard('', content); +} + +function renderPhoneMerakiSection(device) { + const m = device.meraki || {}; + const client = m.client || m; + const connectionType = m.connectionType || client.recentDeviceConnection || 'Unknown'; + + let isOnline = client?.status === 'Online' || client?.status === 'connected' || false; + if (!isOnline && client?.lastSeen) { + const d = new Date(client.lastSeen); + if (!isNaN(d.getTime())) { + const ageMins = (Date.now() - d.getTime()) / 60000; + if (ageMins < 5) isOnline = true; + } + } + const lastSeen = client?.lastSeen ? simpleTimeAgo(new Date(client.lastSeen)) : '—'; + + const cleanMac = client?.mac ? client.mac.toUpperCase().replace(/:/g, '') : '—'; + const ip = client?.ip || '—'; + + // Data usage in MB + let sentMB = 0, recvMB = 0, totalMB = 0; + if (client?.usage) { + sentMB = (client.usage.sent / 1048576).toFixed(3); + recvMB = (client.usage.recv / 1048576).toFixed(3); + totalMB = (client.usage.total / 1048576).toFixed(3); + } + + const statusBadge = ` + + + + + + ${isOnline ? 'Online' : 'Offline'} + `; + + const merakiLink = client?.id && (m.clientUrl || client.clientUrl) + ? `View in Meraki →` + : ''; + + let content = ` +
+

Meraki Client Information

+
+ ${statusBadge} + ${merakiLink} +
+
+ +
+
+ CONNECTION TYPE + ${connectionType} +
+
+ MAC ADDRESS + ${cleanMac} +
+
+ IP ADDRESS + ${ip} +
+
+ LAST SEEN + ${lastSeen} +
+
+ `; + + if (client?.usage) { + content += ` +
+
DATA USAGE (SINCE LAST SEEN)
+
+
+
SENT
+
${sentMB} MB
+
+
+
RECEIVED
+
${recvMB} MB
+
+
+
TOTAL
+
${totalMB} MB
+
+
+
+ `; + } + + // Add detailed port status/config if present (like AV dashboard) + const portStatus = m.portStatus || client?.switchportStatus; + const portConfig = m.portConfig || client?.switchportConfig; + if (portStatus || portConfig) { + if (portStatus) { + content += renderSwitchportStatusSection(portStatus, portConfig); + } + if (portConfig) { + content += renderSwitchportConfigSection(portConfig); + } + } + + return sectionCard('', content); +} + +// ====================== SWITCHPORT STATUS (copied/adapted from AV for phone nodes) +function renderSwitchportStatusSection(portStatus, portConfig) { + const enabledText = portStatus?.enabled !== false ? 'Enabled' : 'Disabled'; + const enabledColor = enabledText === 'Enabled' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'; + + const statusText = portStatus?.status || 'Unknown'; + const isConnected = statusText.toLowerCase() === 'connected' || statusText.toLowerCase() === 'online'; + const statusColor = isConnected ? 'bg-green-600 text-white' : 'bg-red-600 text-white'; + + const speedDuplex = `${portStatus?.speed || '—'} / ${portStatus?.duplex || '—'}`; + const poeAllocated = portStatus?.poe?.isAllocated ? 'Yes' : 'No'; + + const usageKB = portStatus?.usageInKb?.total ? portStatus.usageInKb.total.toLocaleString() : '—'; + const trafficKbps = portStatus?.trafficInKbps?.total ? portStatus.trafficInKbps.total.toFixed(1) : '—'; + + const content = ` +
+
+
${enabledText}
+
+ + ${statusText} +
+
+ +
+
Port ID
${portStatus?.portId || '—'}
+
Is Uplink
${portStatus?.isUplink ? 'Yes' : 'No'}
+
Speed / Duplex
${speedDuplex}
+
PoE Allocated
${poeAllocated}
+
Errors
${portStatus?.errors || 0}
+
Warnings
${portStatus?.warnings || 0}
+
+ +
+
+
Usage
+
${usageKB} KB
+
+
+
Traffic
+
${trafficKbps} Kbps
+
+
+
Power Usage
+
${portStatus?.powerUsageInWh || '—'} Wh
+
+
+
+ `; + + return sectionCard('Switchport Status', content); +} + +// ====================== SWITCHPORT CONFIG (adapted) +function renderSwitchportConfigSection(portConfig) { + const enabledText = portConfig?.enabled !== false ? 'Enabled' : 'Disabled'; + const enabledColor = enabledText === 'Enabled' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'; + + const poeText = portConfig?.poeEnabled ? 'PoE Enabled' : 'PoE Disabled'; + const poeColor = portConfig?.poeEnabled ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300'; + + const stickyCount = (portConfig?.stickyMacAllowList || []).length; + + const content = ` +
+
+
${enabledText}
+
${poeText}
+
+ +
+
Port ID
${portConfig?.portId || '—'}
+
Name
${portConfig?.name || '—'}
+
Type
${portConfig?.type || 'Access'}
+
VLAN
${portConfig?.vlan || '—'}
+
Voice VLAN
${portConfig?.voiceVlan || 'None'}
+
Access Policy
${portConfig?.accessPolicyType || '—'}
+
Sticky MAC Limit
${portConfig?.stickyMacAllowListLimit || '—'}
+
Sticky MAC List
${stickyCount} entries
+
+
+ `; + + return sectionCard('Switchport Configuration', content); +} + +function renderPhoneContextSection(data) { + const prof = data.telephonyProfile || {}; + const pers = data.person || {}; + const dectNet = data.dectNetwork || {}; + const mainNum = data.locationMainNumber; + + let content = '
'; + + if (prof.timeZone || mainNum) { + content += ` +
+
Location / Profile
+ ${prof.timeZone ? `
Timezone: ${prof.timeZone}
` : ''} + ${mainNum ? `
Main Number: ${mainNum}
` : ''} +
+ `; + } + + if (dectNet.name || dectNet.locationName) { + content += ` +
+
DECT Network
+
${dectNet.name || 'DECT Network'}
+ ${dectNet.locationName ? `
${dectNet.locationName}
` : ''} +
+ `; + } + + content += '
'; + + return sectionCard('Context', content); +} + +export { showPhoneModal }; \ No newline at end of file diff --git a/public/test-av-modal.html b/public/test-av-modal.html new file mode 100644 index 0000000..4d05886 --- /dev/null +++ b/public/test-av-modal.html @@ -0,0 +1,107 @@ + + + + + + AV Devices - Store 2477 + + + + +
+

AV Devices • Store 2477

+

Device Overview (No Topology)

+ + +

All Discovered AV Devices

+
+ +
+
+ + + + \ No newline at end of file diff --git a/public/utils/simple-time-ago.js b/public/utils/simple-time-ago.js new file mode 100644 index 0000000..0068cc1 --- /dev/null +++ b/public/utils/simple-time-ago.js @@ -0,0 +1,49 @@ +// public/utils/simple-time-ago.js +export function simpleTimeAgo(input) { + if (!input) return 'never'; + + let date; + try { + if (input instanceof Date) { + date = input; + } else if (typeof input === 'number') { + date = new Date(input); + } else if (typeof input === 'string') { + let cleaned = input.trim(); + if (!/[Z+-]/.test(cleaned)) { + cleaned += 'Z'; + } + date = new Date(cleaned); + } else { + return 'invalid'; + } + + if (isNaN(date.getTime())) return 'invalid'; + + const nowMs = Date.now(); + let diffMs = nowMs - date.getTime(); + + // Cap any "future" time within 24 hours as "recent" (handles timezone skew in static tests) + if (diffMs < 0) { + if (diffMs > -24 * 60 * 60 * 1000) { + diffMs = 0; // treat as now / very recent + } else { + return 'in the future'; // rare case + } + } + + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return `${seconds} seconds ago`; + if (minutes < 60) return `${minutes} minutes ago`; + if (hours < 24) return `${hours} hours ago`; + if (days < 30) return `${days} days ago`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + } catch (err) { + return 'invalid'; + } +} \ No newline at end of file diff --git a/services/avDeviceBuilder.js b/services/avDeviceBuilder.js new file mode 100644 index 0000000..893aeeb --- /dev/null +++ b/services/avDeviceBuilder.js @@ -0,0 +1,15 @@ +// src/services/avDeviceBuilder.js +// +// Thin wrapper around the core AV enrichment orchestrator (rich/build shape). +// All heavy lifting (base collect, relevant Meraki filtering, shared matchers/enrichers, domain attach, topology) +// lives in services/enrichment/avEnrichmentCore.js. +// This keeps backward compat for callers (e.g. /av/devices/build, characterize). + +import { enrichAVForStore } from './enrichment/avEnrichmentCore.js'; + +export async function buildAVDevices(storeNumber) { + // Thin delegation to core (rich shape preserves exact prior contract for build results). + return enrichAVForStore(storeNumber, { shape: 'rich' }); +} + +export default buildAVDevices; \ No newline at end of file diff --git a/services/avDeviceService.js b/services/avDeviceService.js new file mode 100644 index 0000000..d3cc849 --- /dev/null +++ b/services/avDeviceService.js @@ -0,0 +1,79 @@ +// src/services/avDeviceService.js +import { logger } from '../utils/logger.js'; +import { getAVDevicesForModal } from './avModalService.js'; + +export async function getAVDevicesForStore(storeNumber) { + const storeNum = String(storeNumber).trim(); + logger('av:service', `Fetching AV devices + full modal data for store ${storeNum}`); + + try { + const modalResult = await getAVDevicesForModal(storeNum); + + if (!modalResult.success) { + return { success: false, message: modalResult.message }; + } + + const enrichedDevices = modalResult.clients || []; // these are already enriched + + // Card list (simple) + const clients = enrichedDevices.map(dev => ({ + identifier: dev.identifier, + deviceType: dev.deviceType || 'wired-client', + name: dev.name || 'AV Device', + connectionType: dev.connectionType || 'Wired', + ip: dev.ip || '—', + mac: dev.mac || '—', + vlan: dev.vlan || '—', + })); + + // Full lookup map for modals + const fullDevicesMap = {}; + enrichedDevices.forEach(dev => { + const key = (dev.identifier || '').toUpperCase().trim(); + if (key) { + fullDevicesMap[key] = { + deviceType: dev.deviceType || 'wired-client', + data: dev, // ← full enriched object + identifier: dev.identifier + }; + } + }); + + logger('av:service', `Pre-loaded ${enrichedDevices.length} full devices for modals`); + + return { + success: true, + storeNumber: storeNum, + clients, + fullDevices: fullDevicesMap + }; + + } catch (err) { + logger('av:service', `Error: ${err.message}`, 'error'); + return { success: false, message: err.message }; + } +} + +/** + * DEPRECATED / fallback only — now we use pre-loaded data + * Keep this for a short time in case you have other callers + */ +export async function getShapedDeviceData(storeNumber, identifier) { + // For now, just call the full one and filter (won't be used after frontend update) + const result = await getAVDevicesForStore(storeNumber); + if (!result.success) return result; + + const key = identifier.toUpperCase().trim(); + const device = result.fullDevices[key]; + + if (!device) { + return { success: false, message: `Device "${identifier}" not found` }; + } + + return { + success: true, + deviceType: device.deviceType, + data: device.data, + identifier: device.identifier + }; +} \ No newline at end of file diff --git a/services/avEnricher.js b/services/avEnricher.js new file mode 100644 index 0000000..7132eb3 --- /dev/null +++ b/services/avEnricher.js @@ -0,0 +1,17 @@ +// src/services/avEnricher.js +// +// Thin wrapper for dashboard list enrichment. +// The actual logic lives in avEnrichmentCore (buildDashboardList) for unification. +// Supports direct rawData for backward compat with any external callers. + +import { logger } from '../utils/logger.js'; +import { buildDashboardList } from './enrichment/avEnrichmentCore.js'; + +export async function enrichAVDevices(rawData) { + if (!rawData || typeof rawData !== 'object' || (!rawData.mdm && !rawData.atlas)) { + logger('av:enricher', 'enrichAVDevices called without valid rawData from collect; returning []', 'warn'); + return []; + } + return buildDashboardList(rawData); +} + diff --git a/services/avModalService.js b/services/avModalService.js new file mode 100644 index 0000000..8dc930d --- /dev/null +++ b/services/avModalService.js @@ -0,0 +1,56 @@ +// src/services/avModalService.js +import { logger } from '../utils/logger.js'; +import { enrichAVForStore } from './enrichment/avEnrichmentCore.js'; + +export async function getAVDevicesForModal(storeNumber) { + const storeNum = String(storeNumber).trim(); + logger('av:modal', `Building enriched AV data for store ${storeNum}`); + + try { + const enrichedDevices = await enrichAVForStore(storeNum, { shape: 'dashboard' }); + + // Simple clients for the grid only + const clients = enrichedDevices.map(dev => ({ + identifier: dev.identifier, + deviceType: dev.isAtlasAmp ? 'wired-client' : (dev.meraki?.isWireless ? 'wireless-client' : 'wired-client'), + name: dev.name, + connectionType: dev.meraki?.connectionType || 'Wired', + ip: dev.ip || '—', + mac: dev.mac || '—', + vlan: dev.vlan || '—', + })); + + // Build fullDevices map - use the ORIGINAL enriched device + const fullDevicesMap = {}; + enrichedDevices.forEach(dev => { + const key = (dev.identifier || '').toUpperCase().trim(); + if (key) { + fullDevicesMap[key] = { + deviceType: dev.isAtlasAmp ? 'wired-client' : (dev.meraki?.isWireless ? 'wireless-client' : 'wired-client'), + data: { ...dev }, // spread to ensure a clean full copy + identifier: dev.identifier + }; + } + }); + + // Debug AMP specifically + if (fullDevicesMap['US002477AMP']) { + const ampData = fullDevicesMap['US002477AMP'].data; + logger('av:modal', `AMP in fullDevicesMap - isAtlasAmp: ${ampData.isAtlasAmp}`); + logger('av:modal', `AMP full data keys: ${Object.keys(ampData)}`); + } + + logger('av:modal', `Pre-loaded ${enrichedDevices.length} enriched devices → ${Object.keys(fullDevicesMap).length} in lookup map`); + + return { + success: true, + storeNumber: storeNum, + clients, + fullDevices: fullDevicesMap + }; + + } catch (err) { + logger('av:modal', `Error: ${err.message}`, 'error'); + return { success: false, message: err.message }; + } +} \ No newline at end of file diff --git a/services/deviceService.js b/services/deviceService.js new file mode 100644 index 0000000..5197d66 --- /dev/null +++ b/services/deviceService.js @@ -0,0 +1,16 @@ +// src/services/deviceService.js +// +// Thin wrapper around the core AV enrichment orchestrator (chat shape). +// All heavy lifting (base collect, relevant Meraki filtering, shared matchers/enrichers, domain attach) lives in services/enrichment/*. +// This keeps backward compat for callers (commands, characterize, avEnricher, etc). + +import { enrichAVForStore } from './enrichment/avEnrichmentCore.js'; + +export async function collectDeviceStatus(storeNumber) { + // Thin delegation to core (chat shape preserves exact prior contract). + return enrichAVForStore(storeNumber, { shape: 'chat' }); +} + +export async function enrichDevices(rawData) { + return rawData.mdm?.data || []; +} diff --git a/services/enrichment/avEnrichmentCore.js b/services/enrichment/avEnrichmentCore.js new file mode 100644 index 0000000..93fdd9d --- /dev/null +++ b/services/enrichment/avEnrichmentCore.js @@ -0,0 +1,470 @@ +// services/enrichment/avEnrichmentCore.js +// +// Core orchestrator for AV enrichment. +// Single place that composes: +// - base collection (MDM + Atlas via shared filters) +// - relevant Meraki prefilters for ports/clients +// - parallel domain (RED/Opti) +// - full Meraki enrichment (clients + switches/APs via shared) +// - attach domain data with shape adapters +// - final assembly for different output shapes +// +// Shapes: +// 'chat' -> same shape as old collectDeviceStatus (for /avstatus, commands) +// 'rich' -> same shape as old buildAVDevices (for /av/devices/build + topology) +// 'dashboard'-> the enriched list (for modals / avEnricher) - fully unified now +// +// This centralizes rate-limit smarts (relevant only) and makes future expansion (new sources, new commands) +// much easier without copy/paste drift. +// +// Thin callers in deviceService / avDeviceBuilder / avEnricher delegate here + adapt if needed. + +import { logger } from '../../utils/logger.js'; +import { collectBaseAVData, filterAVDevices } from './filters.js'; +import { + getClientsForStore, + getPortsForStore +} from '../../integrations/meraki/clients.js'; +import { getREDStatusForStore } from '../../integrations/red/players.js'; +import { getOptiSignStatus } from '../../integrations/optisigns/client.js'; +import { findMerakiNetwork } from '../../integrations/meraki/networks.js'; +import { enrichAllMerakiData, attachMerakiClientWithPorts, enrichWirelessDetails, enrichMerakiDeviceDetails } from './merakiEnrichment.js'; +import { attachDomainData } from './domainEnrichment.js'; +import { enrichDomainData as sharedEnrichDomainData } from './domainEnrichment.js'; +import { findBestMerakiClientMatch } from './merakiMatcher.js'; +import { normalizeMac } from './normalizers.js'; +import { normalizePlayerName } from '../../utils/normalize.js'; +import { + getAllMerakiDevices, + getMerakiTopology, +} from '../../integrations/meraki/devices.js'; + +// ====================== CHAT ATTACHERS (MDM + Atlas + domain) ====================== +// Moved here from deviceService to allow core to own the chat composition without cycles. +// These are chat-slim specific (full red object etc flow via domain attach). + +export async function enrichMdmDevices(mdmResult, enrichedMeraki, redResult, optisignsResult) { + let mdmDevices = mdmResult.value || []; + const redPlayers = redResult.value || []; + const optiDevices = optisignsResult.value?.devices || []; + + // Base collection already filtered (unified), no need to re-filter here + logger('device:service', `MDM from unified base: ${mdmDevices.length} (already filtered)`, 'debug'); + + // First pass: meraki matching per MDM device (using shared) + const mdmWithMeraki = await Promise.all(mdmDevices.map(async (mdmDevice) => { + // Be defensive: raw MDM objects may expose UserName / DeviceFriendlyName instead of friendlyName + const friendly = mdmDevice.friendlyName || mdmDevice.DeviceFriendlyName || mdmDevice.UserName || mdmDevice.name || ''; + const normName = normalizePlayerName(friendly).toLowerCase().trim(); + const mdmMac = normalizeMac(mdmDevice.mac || mdmDevice.serial || mdmDevice.MacAddress || ''); + + const matchDevice = { + identifier: friendly, + mdmData: mdmDevice + }; + const matched = findBestMerakiClientMatch(matchDevice, enrichedMeraki.devices || []); + let matchedClient = matched || (enrichedMeraki.devices || []).find(c => { + const clientMeraki = c.meraki || c; + const cName = clientMeraki.name || clientMeraki.description || clientMeraki.UserName || ''; + return ( + normalizeMac(clientMeraki.mac) === mdmMac || + normalizePlayerName(cName).toLowerCase().trim() === normName + ); + }); + + const merakiData = matchedClient ? { ... (matchedClient.meraki || matchedClient) } : {}; + + return { + ...mdmDevice, + meraki: merakiData + }; + })); + + // Second pass: unified domain attach (RED+Opti) for the whole list (slim for chat) + attachDomainData(mdmWithMeraki, redPlayers, optiDevices, { + identifierField: 'friendlyName', + optiShape: 'display', // slim display for /avstatus + onlyForMSCRed: false, + onlyForVWLEDopti: false + }); + + return mdmWithMeraki; +} + +export function enrichAtlasDevices(atlasResult, enrichedMeraki) { + const atlasDevices = atlasResult.value || []; + + return atlasDevices.map(atlasDev => { + const state = atlasDev.state || {}; + const atlasIp = (state.IpAddress || '').trim(); + const atlasNameLower = (atlasDev.name || atlasDev.displayName || '').toLowerCase(); + + // Match Atlas to enriched client using shared advanced matcher (for name strategies) + ip fallback + // Pass ip so the general matcher can use its IP strategy (consistent with rich path) + let matchedClient = findBestMerakiClientMatch({ + identifier: atlasDev.name || atlasDev.displayName || '', + ip: atlasIp + }, enrichedMeraki.devices || []); + + if (!matchedClient) { + // Fallback search like in enrichMdmDevices, to ensure we attach Meraki for AMPs even if + // exact matcher misses due to list shape or naming (by mac, name, or ip). + matchedClient = (enrichedMeraki.devices || []).find(c => { + const clientMeraki = c.meraki || c; + const cName = clientMeraki.name || clientMeraki.description || clientMeraki.UserName || ''; + const cIp = (clientMeraki.ip || '').trim(); + const devMac = normalizeMac( atlasDev.mac || (atlasDev.state && atlasDev.state.MacAddress) || '' ); + return ( + (devMac && normalizeMac(clientMeraki.mac) === devMac) || + normalizePlayerName(cName).toLowerCase().trim() === normalizePlayerName(atlasDev.name || '').toLowerCase().trim() || + (atlasIp && cIp === atlasIp) + ); + }); + } + + const merakiData = matchedClient + ? { ...(matchedClient.meraki || matchedClient) } + : {}; + + return { + ...atlasDev, + meraki: merakiData, // ← flat meraki object + apDetails: matchedClient?.apDetails || null, + switchDetails: matchedClient?.switchDetails || null + }; + }); +} + +// ====================== MAIN ENTRY ====================== + +/** + * Main entry: enrich AV data for a store, returning shape-specific result. + * For 'chat' we preserve exact prior collectDeviceStatus contract (so existing commands + char + avEnricher unchanged). + */ +export async function enrichAVForStore(storeNumber, { shape = 'chat' } = {}) { + const storeNum = String(storeNumber).trim(); + logger('av:core', `enrichAVForStore(${storeNum}, shape=${shape})`, 'debug'); + + if (shape === 'chat') { + return collectDeviceStatusViaCore(storeNum); + } + + if (shape === 'rich' || shape === 'build') { + return buildAVDevicesViaCore(storeNum); + } + + if (shape === 'dashboard' || shape === 'enricher') { + const chatShape = await collectDeviceStatusViaCore(storeNum); + return buildDashboardList(chatShape); + } + + throw new Error(`Unknown shape: ${shape}`); +} + +/** + * Chat/collect shape implemented via the shared pieces (no behavior change). + * This is the body that used to live in deviceService.collectDeviceStatus. + */ +async function collectDeviceStatusViaCore(storeNum) { + logger('device:service', `Starting device status collection for store ${storeNum} (via core)`, 'debug'); + + // Get clients first so we can prefilter switches for ports (to interrogate fewer switches and avoid rate limits) + const clientsData = await getClientsForStore(storeNum); + const merakiClientsResultRaw = { value: clientsData }; + const clientsForPorts = Array.isArray(clientsData) ? clientsData : (clientsData?.clients || []); + const relevantSwitchesForPorts = new Set(); + for (const c of clientsForPorts) { + const conn = (c.recentDeviceConnection || '').toLowerCase(); + if (c.recentDeviceSerial && !conn.includes('wireless')) { + relevantSwitchesForPorts.add(c.recentDeviceSerial); + } + } + + // Use unified base collection for MDM + Atlas + const baseAV = await collectBaseAVData(storeNum, { includeDomain: true }); + // For chat/enrichMdmDevices + avStatus consumers, ensure top-level friendlyName/name + lastSeen (camelCase) + // (the raw MDM objects from integration use PascalCase like UserName / DeviceFriendlyName / LastSeen / LastSystemSampleTime; + // prepareMDMBaseDevice normalizes into mdmDataSummary but for chat we extract .mdmData raw for full original data. + // Patch the expected top-level fields so display (last seen, name) + matching in enrichMdm + domain attach all work correctly. + // This keeps the per-device Meraki/port attachment distinct.) + const mdmBase = baseAV.filter(d => d.source === 'mdm').map(d => { + const raw = { ...(d.mdmData || d) }; + const bestName = raw.friendlyName || raw.DeviceFriendlyName || raw.UserName || d.identifier || raw.name || ''; + if (!raw.friendlyName) raw.friendlyName = bestName; + if (!raw.name) raw.name = bestName; + raw.lastSeen = raw.lastSeen || raw.LastSeen || raw.LastSystemSampleTime || ''; + return raw; + }); + const atlasBase = baseAV.filter(d => d.source === 'atlas').map(d => d.atlasData); + + const [ + merakiPortsResultRaw, + redResult, + optisignsResult + ] = await Promise.allSettled([ + getPortsForStore(storeNum, relevantSwitchesForPorts), + getREDStatusForStore(storeNum), + getOptiSignStatus(storeNum) + ]); + + const mdmResult = { status: 'fulfilled', value: mdmBase }; + const atlasResult = { status: 'fulfilled', value: atlasBase }; + + const networkInfo = await findMerakiNetwork(storeNum); + + // FULL MERAKI via the shared (now in enrichment layer) + const merakiEnriched = await enrichAllMerakiData( + networkInfo?.id, + merakiClientsResultRaw, + merakiPortsResultRaw, + networkInfo + ); + + // Enrich MDM with Meraki + RED + OptiSigns (chat slim shape) + const enrichedMdm = await enrichMdmDevices( + mdmResult, + merakiEnriched, + redResult, + optisignsResult + ); + + const enrichedAtlas = enrichAtlasDevices(atlasResult, merakiEnriched); + + logger('device:service', + `Collected for store ${storeNum}: MDM=${enrichedMdm.length}, Atlas=${enrichedAtlas.length}, ` + + `Meraki clients=${merakiEnriched.devices.length}`); + + return { + mdm: { status: mdmResult.status, data: enrichedMdm }, + atlas: { status: atlasResult.status, data: enrichedAtlas }, + optisigns: prepareResult(optisignsResult, 'optisigns'), + red: prepareResult(redResult, 'red'), + meraki: merakiEnriched, + topology: null + }; +} + +/** + * Rich/build shape implemented via shared pieces (no behavior change from old avDeviceBuilder). + * Replicates the orchestration but delegates client/ports/wireless/details/domain to shared, + * keeps relevant-only for rate limits, assembles exact prior result shape. + */ +async function buildAVDevicesViaCore(storeNum) { + logger('av:builder', `Building AV devices for store ${storeNum} (via core)`, 'debug'); + + try { + const baseDevices = await collectBaseAVData(storeNum, { includeDomain: false }); + + if (!baseDevices || baseDevices.length === 0) { + logger('av:builder', `No base devices found for store ${storeNum}`, 'warn'); + } + + const merakiData = await getClientsForStore(storeNum); + + // Prefilter switches for ports using the clients' connected device serials (only wired ones need switch ports) + const relevantSwitchesForPorts = new Set(); + for (const c of (merakiData.clients || [])) { + const conn = (c.recentDeviceConnection || '').toLowerCase(); + if (c.recentDeviceSerial && !conn.includes('wireless')) { + relevantSwitchesForPorts.add(c.recentDeviceSerial); + } + } + const portsData = await getPortsForStore(storeNum, relevantSwitchesForPorts); + + // Step 1: Meraki client + port enrichment (uses shared) + let enrichedDevices = await enrichWithMerakiViaCore( + baseDevices, + merakiData.clients || [], + merakiData.network?.url || '', + portsData + ); + + // Step 2: Wireless details (only for wireless clients) - shared + enrichedDevices = await enrichWirelessDetails(enrichedDevices, merakiData.network?.id); + + // Step 3: Meraki device details (switches + APs with wirelessStatus) - relevant only + const allMerakiDevices = merakiData.network + ? await getAllMerakiDevices(merakiData.network.id) + : []; + + const relevantSerials = new Set(); + for (const device of enrichedDevices) { + const c = device.meraki?.client || {}; + if (c.recentDeviceSerial) relevantSerials.add(c.recentDeviceSerial); + if (c.switchSerial) relevantSerials.add(c.switchSerial); + if (device.meraki?.apSerial) relevantSerials.add(device.meraki.apSerial); + if (device.meraki?.switchSerial) relevantSerials.add(device.meraki.switchSerial); + } + const relevantDevices = allMerakiDevices.filter(d => relevantSerials.has(d.serial)); + logger('av:builder', `Enriching details only for ${relevantDevices.length}/${allMerakiDevices.length} relevant Meraki devices (to reduce rate limits)`); + + const merakiDeviceDetails = await enrichMerakiDeviceDetails(merakiData.network?.id, relevantDevices); + + // Step 4: Topology + const topology = await getMerakiTopology(merakiData.network?.id); + + // Step 5: Domain data (RED + OptiSigns) — MUST be last, rich shape + enrichedDevices = await sharedEnrichDomainData(enrichedDevices, storeNum, { optiShape: 'rich' }); + + const result = { + success: true, + storeNumber: storeNum, + network: merakiData.network || null, + merakiDevices: allMerakiDevices, + merakiDeviceDetails, + merakiTopology: topology, + deviceCount: enrichedDevices.length, + devices: enrichedDevices, + lastUpdated: new Date().toISOString() + }; + + logger('av:builder', `Build complete for store ${storeNum} (${enrichedDevices.length} devices)`); + return result; + + } catch (err) { + logger('av:builder', `Build failed for store ${storeNum}: ${err.message}`, 'error'); + throw err; + } +} + +async function enrichWithMerakiViaCore(baseDevices, allMerakiClients, networkUrl = '', portsData = null) { + const enriched = []; + const portStatusCache = new Map(); + const portConfigs = (portsData && (portsData.ports || portsData)) || []; + + logger('av:meraki', `Enriching ${baseDevices.length} devices with Meraki data`, 'debug'); + + for (const device of baseDevices) { + await attachMerakiClientWithPorts(device, allMerakiClients, portConfigs, portStatusCache, networkUrl); + enriched.push(device); + } + + // Extra visibility for the reported case (multiple historical client records for same AV) + enriched.filter(d => /2477/i.test(d.identifier || '')).forEach(d => { + const c = d.meraki?.client || {}; + const mdm = d.mdmData || d.mdm || {}; + const mdmLast = mdm.LastSeen || mdm.lastSeen || mdm.LastSystemSampleTime || (mdm.mdmDataSummary && mdm.mdmDataSummary.lastSeen) || 'n/a'; + logger('av:meraki', `2477 device after attach: ${d.identifier} → client lastSeen=${c.lastSeen || 'n/a'} port/switchport=${c.switchport || c.portNumber || c.recentDevicePort || 'n/a'} status=${c.status || 'n/a'} recentDev=${c.recentDeviceSerial || 'n/a'} mdmLastSeen=${mdmLast}`); + }); + + logger('av:meraki', `Meraki enrichment complete: ${enriched.filter(d => d.meraki?.client).length} matched`, 'debug'); + return enriched; +} + +// ====================== DASHBOARD / AV ENRICHER LIST BUILDER ====================== +// Moved here from avEnricher.js for full unification. 'dashboard' shape now returns the +// enriched list directly (used by avModalService, and avEnricher for compat). +// Uses 'raw' opti shape + identifier 'name' on the built objects. + +const normalizeName = name => normalizePlayerName(name || '').toLowerCase().trim(); + +function getCategory(name) { + const u = name.toUpperCase(); + if (u.includes('AMP')) return 'AMP'; + if (u.includes('VW')) return 'VW'; + if (u.includes('LED')) return 'LED'; + if (u.includes('MSC')) return 'MSC'; + return 'Other'; +} + +function findMerakiFallback(deviceName, merakiClients) { + if (!Array.isArray(merakiClients)) return {}; + const normName = normalizeName(deviceName); + return merakiClients.find(c => { + const m = c.meraki || c || {}; + return normalizeName(m.description || m.user || m.name || '') === normName; + }) || {}; +} + +function buildDashboardList(rawData) { + const { mdm, atlas, meraki, red, optisigns } = rawData; + + logger('av:dashboard', `Enriching AV devices - MDM:${mdm?.data?.length || 0}, Atlas:${atlas?.data?.length || 0}`); + + const avDevices = []; + + // MDM Players (VW, LED, MSC) - use shared filter (strict, no domain here) + const mdmPlayers = filterAVDevices(mdm?.data || [], false); + + for (const device of mdmPlayers) { + avDevices.push(enrichOneDevice(device, 'MDM', rawData)); + } + + // Atlas AMPs + const atlasAmps = (atlas?.data || []).filter(d => + (d.name || d.displayName || '').toLowerCase().includes('amp') + ); + + for (const device of atlasAmps) { + avDevices.push(enrichOneDevice(device, 'Atlas', rawData)); + } + + // Domain (red/opti) attachment now unified via shared (use 'raw' shape to preserve previous dashboard behavior) + attachDomainData(avDevices, rawData.red?.data || [], rawData.optisigns?.data?.devices || [], { + identifierField: 'name', + optiShape: 'raw', + onlyForMSCRed: false, + onlyForVWLEDopti: false + }); + + logger('av:dashboard', `Final enriched AV devices: ${avDevices.length}`); + return avDevices; +} + +function enrichOneDevice(sourceDevice, sourceType, rawData) { + const name = sourceDevice.friendlyName || sourceDevice.name || sourceDevice.displayName || 'Unknown'; + const category = getCategory(name); + const isAtlasAmp = category === 'AMP'; + + // Use the meraki object already attached (best source) + let merakiData = sourceDevice.meraki || {}; + + // If it's empty or not an object, do a strict fallback search using the shared advanced matcher + if (!merakiData || typeof merakiData !== 'object' || Object.keys(merakiData).length < 5) { + const matchDevice = { identifier: name }; + const matched = findBestMerakiClientMatch(matchDevice, rawData.meraki?.devices || []); + merakiData = matched || findMerakiFallback(name, rawData.meraki?.devices || []); + } + + const switchportStatus = (merakiData && merakiData.switchportStatus) || {}; + const switchportConfig = (merakiData && merakiData.switchportConfig) || {}; + + return { + identifier: name, + name: name, + deviceCategory: category, + source: sourceType, + isAtlasAmp, + + ip: merakiData.ip || sourceDevice.state?.IpAddress || sourceDevice.ip, + mac: merakiData.mac || sourceDevice.mac || sourceDevice.serialNumber, + vlan: merakiData.vlan, + switchport: merakiData.switchport || merakiData.portNumber, + status: merakiData.status || 'Online', + lastSeen: merakiData.lastSeen || sourceDevice.lastSeen, + + meraki: merakiData, + switchportStatus, + switchportConfig, + + mdm: sourceType === 'MDM' ? sourceDevice : null, + atlas: isAtlasAmp ? sourceDevice : null, + + // red/optisigns attached post-build via shared attachDomainData (raw shape for dashboard) + // (removed from here to unify) + + usage: merakiData.usage || { sent: 0, recv: 0 } + }; +} + +function prepareResult(result, name) { + if (result.status === 'fulfilled') return { status: 'success', data: result.value }; + logger('device:service', `${name} fetch failed: ${result.reason?.message || result.reason}`, 'warn'); + return { status: 'failed', error: result.reason?.message || result.reason || 'Unknown error' }; +} + +export { buildDashboardList }; // for avEnricher compat / direct dashboard list use + +export default { + enrichAVForStore, +}; diff --git a/services/enrichment/domainEnrichment.js b/services/enrichment/domainEnrichment.js new file mode 100644 index 0000000..d6e4903 --- /dev/null +++ b/services/enrichment/domainEnrichment.js @@ -0,0 +1,92 @@ +// services/enrichment/domainEnrichment.js +// +// Shared helpers for domain (RED + OptiSigns) attachment and fetching. +// This unifies the previously duplicated logic for attaching RED/OptiSigns +// across chat (deviceService), rich build (avDeviceBuilder), and dashboard (avEnricher). +// +// Uses the per-type matchers and creators for consistency. +// Incremental step toward unified top-level AV enrichment. + +import { getOptiSignStatus } from '../../integrations/optisigns/client.js'; +import { getREDStatusForStore } from '../../integrations/red/players.js'; +import { findBestRedMatch } from './redMatcher.js'; +import { findBestOptiSignsMatch, createOptiSignsDisplay, createRichOptiSigns } from './optisignsMatcher.js'; + +/** + * Fetch RED and OptiSigns data for a store (parallel, safe). + * Returns lists ready for attachment. + */ +export async function fetchDomainData(storeNum) { + const [optiData, redPlayers] = await Promise.allSettled([ + getOptiSignStatus(storeNum), + getREDStatusForStore(storeNum) + ]); + + return { + optiDevices: optiData.status === 'fulfilled' ? (optiData.value?.devices || []) : [], + redList: redPlayers.status === 'fulfilled' ? (redPlayers.value || []) : [] + }; +} + +/** + * Attach RED and OptiSigns to a list of devices in-place. + * Supports different shapes for different consumers (chat vs rich vs raw for dashboard). + * + * @param {Array} devices - list of device objects (must have identifier or friendlyName/name) + * @param {Array} redList + * @param {Array} optiList + * @param {Object} [options] + * @param {string} [options.identifierField='identifier'] - field to use for lookup (fallback to friendlyName/name) + * @param {string} [options.optiShape='rich'] - 'rich' | 'display' | 'raw' (raw = just the matched device obj) + * @param {boolean} [options.onlyForMSCRed=true] + * @param {boolean} [options.onlyForVWLEDopti=true] + * @returns {Array} the devices (mutated) + */ +export function attachDomainData(devices, redList = [], optiList = [], options = {}) { + const { + identifierField = 'identifier', + optiShape = 'rich', + onlyForMSCRed = true, + onlyForVWLEDopti = true + } = options; + + const redPlayers = redList || []; + const optiDevices = optiList || []; + + for (const device of devices) { + const id = device[identifierField] || device.friendlyName || device.name || ''; + const idUpper = (id || '').toUpperCase(); + + // RED for MSC* + if (!onlyForMSCRed || idUpper.includes('MSC')) { + device.red = findBestRedMatch(id, redPlayers) || null; + } else { + device.red = null; + } + + // Opti for VW/LED + if (!onlyForVWLEDopti || idUpper.includes('VW') || idUpper.includes('LED')) { + const rawOpti = findBestOptiSignsMatch(id, optiDevices); + if (optiShape === 'raw') { + device.optisigns = rawOpti || null; + } else if (optiShape === 'display') { + device.optisigns = createOptiSignsDisplay(rawOpti); + } else { + device.optisigns = createRichOptiSigns(rawOpti); + } + } else { + device.optisigns = null; + } + } + + return devices; +} + +/** + * Convenience: fetch + attach for a list of devices. + * Used by rich paths that want to do domain last. + */ +export async function enrichDomainData(devices, storeNum, options = {}) { + const { optiDevices, redList } = await fetchDomainData(storeNum); + return attachDomainData(devices, redList, optiDevices, options); +} diff --git a/services/enrichment/filters.js b/services/enrichment/filters.js new file mode 100644 index 0000000..b4f16d7 --- /dev/null +++ b/services/enrichment/filters.js @@ -0,0 +1,156 @@ +// services/enrichment/filters.js +// +// Shared filters and normalizers for AV devices (MDM + Atlas + domain). +// Extracted to eliminate duplication between deviceService (chat) and avDeviceBuilder (rich build). +// avEnricher can also use for consistency. +// +// Includes: +// - isAVDevice / filterAVDevices (with option to include domain/RED/Opti) +// - prepareMDMBaseDevice, prepareAtlasBaseDevice for consistent base shape +// - collectBaseAVData (high-level, uses integrations but normalizes output) + +import { getMDMDevices2 } from '../../integrations/mdm/client.js'; +import { getAVMDMDevices } from '../../integrations/mdm/client.js'; // note: this one does client-side AV filter on UserName +import { getAtlasDeviceForStore } from '../../integrations/atlas/devices.js'; +import { normalizePlayerName } from './normalizers.js'; + +/** + * AV device name patterns. + * Note: deviceService historically included RED/OptiSigns in the MDM filter + * so that non-MDM "AV" like RED players could be enriched in the MDM list. + * Builder uses getAVMDMDevices (stricter) + separate domain attach. + */ +const STRICT_AV_PATTERN = /(VW|MSC|LED|AppleTV)/i; +const AV_WITH_DOMAIN_PATTERN = /(VW|MSC|LED|AppleTV|RED|OptiSigns)/i; + +/** + * Check if a device (MDM raw or mapped) matches AV criteria. + * @param {object} device + * @param {boolean} [includeDomain=false] - include RED/OptiSigns players + */ +export function isAVDevice(device, includeDomain = false) { + if (!device) return false; + // Support multiple shapes: raw mapped, builder prepared (has identifier + *_DataSummary), etc. + const name = ( + device.friendlyName || + device.name || + device.DeviceFriendlyName || + device.UserName || + device.identifier || + (device.mdmDataSummary && device.mdmDataSummary.friendlyName) || + (device.atlasDataSummary && device.atlasDataSummary.name) || + '' + ).toUpperCase(); + const pattern = includeDomain ? AV_WITH_DOMAIN_PATTERN : STRICT_AV_PATTERN; + return pattern.test(name); +} + +/** + * Filter list to AV devices. + * @param {Array} devices + * @param {boolean} [includeDomain=false] + */ +export function filterAVDevices(devices, includeDomain = false) { + if (!Array.isArray(devices)) return []; + return devices.filter(d => isAVDevice(d, includeDomain)); +} + +/** + * Normalize a single MDM device (from getMDMDevices2 or raw) into the common base shape + * used by rich build (avDeviceBuilder) and chat enrichment. + * Keeps original data under mdmData / mdmDataSummary. + */ +export function prepareMDMBaseDevice(mdmRaw, storeNum) { + if (!mdmRaw) return null; + const identifier = (mdmRaw.DeviceFriendlyName || mdmRaw.friendlyName || '').trim(); + if (!identifier) return null; + + const summary = { + friendlyName: identifier, + username: mdmRaw.UserName || '', + serialNumber: mdmRaw.SerialNumber || '', + macAddress: mdmRaw.MacAddress || '', + lastSeen: mdmRaw.LastSeen || '', + platform: mdmRaw.Platform || '', + model: mdmRaw.Model || '', + osVersion: mdmRaw.OperatingSystem || '', + complianceStatus: mdmRaw.ComplianceStatus || '', + enrollmentStatus: mdmRaw.EnrollmentStatus || '', + userEmail: mdmRaw.UserEmailAddress || '' + }; + + return { + identifier, + source: 'mdm', + mdmData: mdmRaw, + mdmDataSummary: summary, + meraki: null, + optisigns: null, + red: null + }; +} + +/** + * Normalize Atlas device into common base shape (as in avDeviceBuilder). + */ +export function prepareAtlasBaseDevice(amp, storeNum, index = 0) { + if (!amp || typeof amp !== 'object') return null; + // Fallback identifier must use 6-digit padded store (e.g. US000305AMP) to match Atlas naming convention. + const padded = String(storeNum).trim().padStart(6, '0'); + let identifier = amp.name || `US${padded}AMP`; + if (index > 0) identifier += (index + 1); + + return { + identifier, + source: 'atlas', + atlasData: amp, + atlasDataSummary: { + name: identifier, + macAddress: amp.mac || '', + ipAddress: amp.state?.IpAddress || amp.ipAddress || '', + status: amp.status || '', + firmware: amp.firmware?.version || '', + model: amp.model?.name || '' + }, + meraki: null, + optisigns: null, + red: null + }; +} + +/** + * High-level: collect normalized base AV devices (MDM + Atlas) for a store. + * Uses getAVMDMDevices (which does AV filter) + getAtlas, then prepares consistent shape. + * includeDomain affects whether domain players are considered in MDM filter (but getAVMDM is strict). + * For full "with domain" like old deviceService, caller can post-filter or use getMDMDevices2 + filter. + * + * Returns array in the "base" shape expected by enrichers (with source, identifier, *_Data, *_DataSummary). + */ +export async function collectBaseAVData(storeNum, { includeDomain = false } = {}) { + const store = String(storeNum).trim(); + const [mdmAV, atlasRaw] = await Promise.allSettled([ + getAVMDMDevices(store), + getAtlasDeviceForStore(store) + ]); + + const base = []; + + const mdmList = mdmAV.status === 'fulfilled' ? (mdmAV.value || []) : []; + for (const mdm of mdmList) { + const prepared = prepareMDMBaseDevice(mdm, store); + if (prepared) base.push(prepared); + } + + const atlasList = atlasRaw.status === 'fulfilled' ? (atlasRaw.value || []) : []; + const atlasArray = Array.isArray(atlasList) ? atlasList : (atlasList ? [atlasList] : []); + atlasArray.forEach((amp, idx) => { + const prepared = prepareAtlasBaseDevice(amp, store, idx); + if (prepared) base.push(prepared); + }); + + // If caller wants "include domain" style, they can further process, but getAVMDM already filters strict AV. + // For compatibility with old deviceService filter that included RED/Opti in MDM list, use getMDMDevices2 + filterAVDevices(..., true) + // This function aims for the common base used in rich paths. + + return base; +} diff --git a/services/enrichment/index.js b/services/enrichment/index.js new file mode 100644 index 0000000..50db888 --- /dev/null +++ b/services/enrichment/index.js @@ -0,0 +1,13 @@ +// services/enrichment/index.js +// +// Barrel file for shared enrichment utilities. +// This is the recommended import point going forward. + +export * from './merakiMatcher.js'; +export * from './normalizers.js'; +export * from './redMatcher.js'; +export * from './optisignsMatcher.js'; +export * from './domainEnrichment.js'; +export * from './filters.js'; +export * from './merakiEnrichment.js'; +export * from './avEnrichmentCore.js'; diff --git a/services/enrichment/merakiEnrichment.js b/services/enrichment/merakiEnrichment.js new file mode 100644 index 0000000..9a3feac --- /dev/null +++ b/services/enrichment/merakiEnrichment.js @@ -0,0 +1,488 @@ +// services/enrichment/merakiEnrichment.js +// +// Shared Meraki enrichment helpers for AV devices. +// Extracted to reduce duplication between deviceService (chat/collect) and avDeviceBuilder (rich build). +// avEnricher relies on pre-enriched data from collect. +// +// Includes: +// - attachMerakiClient (basic client match + common fields + url) +// - enrichWirelessDetails (the detailed wireless summary used in builder) +// - enrichMerakiDeviceDetails (switches + APs details, relevant-only in callers) +// - enrichSwitchesAndAPsShared (relevant switches+APs with port statuses for chat + wireless; used by deviceService) +// - Re-exports or helpers for switches/APs if we pull more. +// +// Uses shared matcher. + +import { findBestMerakiClientMatch } from './merakiMatcher.js'; +import { getMerakiWirelessStatus, getMerakiDeviceDetail } from '../../integrations/meraki/devices.js'; +import { findMerakiNetwork } from '../../integrations/meraki/networks.js'; +import { + getWirelessClientConnectionStats, + getWirelessClientHealthScores, + getSwitchPortConfig, + getSwitchPortStatus, + getSwitchPortsStatuses +} from '../../integrations/meraki/clients.js'; +import { + getWirelessClientSignalQuality, + getWirelessClientLatency, + getWirelessClientFailedConnections +} from '../../integrations/meraki/devices.js'; +import { logger } from '../../utils/logger.js'; + +/** + * Attach basic Meraki client data to a device (using shared matcher). + * Common across paths. Adds client, connectionType, optional clientUrl. + * Port status/config can be added by callers (differs slightly between chat and build). + * + * @param {Object} device - base device with identifier or friendlyName etc. + * @param {Array} allMerakiClients + * @param {string} [networkUrl=''] - for building clientUrl + * @returns {Object} the device (mutated) + */ +export function attachMerakiClient(device, allMerakiClients, networkUrl = '') { + const merakiClient = findBestMerakiClientMatch(device, allMerakiClients); + + if (merakiClient) { + device.meraki = { + client: merakiClient, + connectionType: merakiClient.recentDeviceConnection === 'Wireless' || + merakiClient.ssid ? 'Wireless' : 'Wired', + }; + + if (networkUrl && merakiClient.id) { + device.meraki.clientUrl = buildMerakiClientUrl(merakiClient.id, { url: networkUrl }); + } + } else { + device.meraki = { + client: null, + connectionType: 'Unknown' + }; + } + + return device; +} + +/** + * Full wireless enrichment (extracted and shared from builder). + * Used for wireless clients in rich builds. + */ +export async function enrichWirelessDetails(devices, networkId) { + if (!networkId) return devices; + + for (const device of devices) { + const client = device.meraki?.client; + if (device.meraki?.connectionType !== 'Wireless' || !client?.id) { + continue; + } + try { + const [connStats, health, signalQuality, latency, failedConns] = await Promise.allSettled([ + getWirelessClientConnectionStats(networkId, client.id), + getWirelessClientHealthScores(networkId, client.id), + getWirelessClientSignalQuality(networkId, client.id), + getWirelessClientLatency(networkId, client.id), + getWirelessClientFailedConnections(networkId, client.id) + ]); + + let connectionDurationSeconds = null; + if (client.firstSeen) { + const firstSeenTime = new Date(client.firstSeen).getTime(); + connectionDurationSeconds = Math.floor((Date.now() - firstSeenTime) / 1000); + } + + device.meraki.wirelessSummary = { + ssid: client.ssid || "—", + apName: client.recentDeviceName || "—", + apSerial: client.recentDeviceSerial || "—", + rssi: signalQuality.status === 'fulfilled' ? signalQuality.value.rssi : null, + snr: signalQuality.status === 'fulfilled' ? signalQuality.value.snr : null, + avgLatencyMs: latency.status === 'fulfilled' ? latency.value.avgLatencyMs : null, + txRate: client.txRate || null, + rxRate: client.rxRate || null, + connectionDurationSeconds, + connectionDurationDisplay: connectionDurationSeconds && connectionDurationSeconds < 86400 * 30 + ? `${Math.floor(connectionDurationSeconds / 3600)}h ${Math.floor((connectionDurationSeconds % 3600) / 60)}m` + : "Long-term (>30 days)", + connectionSuccess: connStats.status === 'fulfilled' ? (connStats.value?.success || 0) : 0, + failedConnectionsCount: failedConns.status === 'fulfilled' ? (failedConns.value?.length || 0) : 0 + }; + + device.meraki.wirelessDetails = { + connectionStats: connStats.status === 'fulfilled' ? connStats.value : null, + healthScores: health.status === 'fulfilled' ? health.value : null, + signalQuality: signalQuality.status === 'fulfilled' ? signalQuality.value : null, + latency: latency.status === 'fulfilled' ? latency.value : null, + failedConnections: failedConns.status === 'fulfilled' ? failedConns.value : [] + }; + } catch (err) { + logger('av:meraki', `Wireless enrichment failed for ${device.identifier}`, 'warn'); + } + } + return devices; +} + +/** + * Enrich list of Meraki devices (switches/APs) with details and wireless status. + * Extracted from avDeviceBuilder. + */ +export async function enrichMerakiDeviceDetails(networkId, allMerakiDevicesList) { + if (!networkId || !allMerakiDevicesList?.length) return {}; + + const detailsMap = {}; + + logger('av:meraki', `Enriching ${allMerakiDevicesList.length} Meraki devices (switches + APs details${allMerakiDevicesList.length < 10 ? ' (relevant subset)' : ''})`); + + for (const dev of allMerakiDevicesList) { + const serial = dev.serial; + if (!serial) continue; + + const isAP = (dev.model || dev.productType || '').toUpperCase().startsWith('MR'); // MR = Meraki wireless AP + + try { + const calls = [getMerakiDeviceDetail(serial)]; + if (isAP) { + calls.push(getMerakiWirelessStatus(serial)); + } + const results = await Promise.allSettled(calls); + + const basicRes = results[0]; + const wirelessRes = isAP ? results[1] : null; + + const fullDevice = basicRes.status === 'fulfilled' ? basicRes.value : dev; + + if (wirelessRes && wirelessRes.status === 'fulfilled' && wirelessRes.value) { + fullDevice.wirelessStatus = wirelessRes.value; + } + + detailsMap[serial] = fullDevice; + } catch (err) { + logger('av:meraki', `Failed enriching device ${serial}: ${err.message}`, 'warn'); + detailsMap[serial] = dev; + } + } + + return detailsMap; +} + +/** + * Shared enrichment for switches + APs details, port statuses (for chat), wireless status (for APs). + * Computes *relevant* serials only from the provided client list (raw or enriched) to avoid rate limits/429s. + * Uses axios-based integrations (consistent, no SDK client needed). + * Returns maps in the shape expected by deviceService's enrichAllMerakiData (for attaching switchDetails/apDetails + * and for bulk port status lookup to dedup /statuses calls). + * + * Can be used by both collect (chat) and build paths. + */ +export async function enrichSwitchesAndAPsShared(networkId, clientList = []) { + const switches = new Map(); + const aps = new Map(); + + if (!networkId) return { switches, aps }; + + // Collect relevant serials from raw client records (direct fields: recentDeviceSerial + recentDeviceConnection) + // or from enriched (meraki.recent... or top level after some processing). + const switchSerials = new Set(); + const apSerials = new Set(); + + for (const dev of clientList || []) { + const m = dev.meraki || dev; // support raw client (flat recentDevice*) or post-enrich (under meraki or flat) + const recentSerial = m.recentDeviceSerial || m.recentDeviceSerial; + const conn = (m.recentDeviceConnection || m.connectionType || '').toLowerCase(); + const isWireless = conn.includes('wireless'); + + if (m.switchSerial) switchSerials.add(m.switchSerial); + if (m.apSerial) apSerials.add(m.apSerial); + if (recentSerial) { + if (isWireless) { + apSerials.add(recentSerial); + } else { + switchSerials.add(recentSerial); + } + } + } + + const allRelevant = new Set([...switchSerials, ...apSerials]); + if (allRelevant.size === 0) { + logger('av:meraki', 'No relevant switches/APs derived from clients (will return empty maps)'); + return { switches, aps }; + } + + logger('av:meraki', `Enriching switches/APs for ${allRelevant.size} relevant serials (switches: ${switchSerials.size}, APs: ${apSerials.size}) from client list`); + + // Enrich switches (detail + batch port *statuses* for the map used by chat port lookup) + for (const serial of switchSerials) { + try { + const deviceInfo = await getMerakiDeviceDetail(serial); + const portStatuses = await getSwitchPortsStatuses(serial); + + switches.set(serial, { + serial, + name: deviceInfo.name || 'MS Switch', + model: deviceInfo.model, + status: deviceInfo.status || 'offline', + lastReportedAt: deviceInfo.lastReportedAt, + ports: portStatuses || [], + }); + } catch (err) { + logger('device:enrich', `Failed to enrich switch ${serial}: ${err.message}`, 'warn'); + switches.set(serial, { serial, name: 'Unknown Switch', status: 'offline', ports: [] }); + } + } + + // Enrich APs (detail + wireless status + channelInfo shape) + for (const serial of apSerials) { + try { + const [deviceInfo, wirelessStatus] = await Promise.allSettled([ + getMerakiDeviceDetail(serial), + getMerakiWirelessStatus(serial) + ]); + + const dev = deviceInfo.status === 'fulfilled' ? deviceInfo.value : { serial }; + const ws = wirelessStatus.status === 'fulfilled' ? wirelessStatus.value : null; + + aps.set(serial, { + serial, + name: (ws && ws.name) || dev.name || 'MR Access Point', + status: (ws && ws.status) || dev.status || 'offline', + lastSeen: ws && ws.lastSeen, + basicServiceSets: (ws && ws.basicServiceSets) || [], + channelInfo: ((ws && ws.basicServiceSets) || []).map(bss => ({ + ssid: bss.ssidName, + channel: bss.channel, + band: bss.band, + power: bss.power, + channelWidth: bss.channelWidth, + bssid: bss.bssid, + })), + }); + } catch (err) { + logger('device:enrich', `Failed to enrich AP ${serial}: ${err.message}`, 'warn'); + aps.set(serial, { serial, name: 'Unknown AP', status: 'offline', channelInfo: [] }); + } + } + + return { switches, aps }; +} + +/** + * Attach port config and status to a Meraki client entry. + * Extracted from enrichMerakiData and legacy enrichDeviceWithMeraki. + * portConfigs come from getPortsForStore result. + * portStatusCache is for getSwitchPortStatus (to batch /statuses fetches). + */ +export async function attachPortConfigAndStatus(client, portConfigs = [], portStatusCache = new Map()) { + const portNum = client.switchport; + const switchSerial = client.recentDeviceSerial; + const isWireless = (client.recentDeviceConnection || '').toLowerCase() === 'wireless'; + + let portConfig = null; + let portStatus = null; + + if (switchSerial && portNum) { + portConfig = portConfigs.find(p => + p.deviceSerial === switchSerial && + String(p.portId || p.number) === String(portNum) + ); + + // Get real port status (async, uses cache) + try { + portStatus = await getSwitchPortStatus(switchSerial, portNum, portStatusCache); + } catch (e) { + logger('meraki:enrich', `Port status attach failed for ${switchSerial}:${portNum}`); + } + } + + // Attach common port fields (merged into the client object) + Object.assign(client, { + portNumber: portNum || (isWireless ? '—' : '?'), + portName: portConfig?.portName || portConfig?.name || (isWireless ? 'Wireless (AP)' : '—'), + portEnabled: portConfig?.enabled, + speed: portConfig?.speed, + duplex: portConfig?.duplex, + poeEnabled: portConfig?.poeEnabled, + poePower: portConfig?.poePower || 0, + accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—', + allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [], + switchportStatus: portStatus, + switchportConfig: portConfig, + switchSerial: switchSerial || '—', + deviceName: client.recentDeviceName || 'SWITCH', + connectionType: isWireless ? 'Wireless' : 'Wired', + }); + + return client; +} + +/** + * Convenience: attach basic Meraki client + ports in one go (for paths that want ports immediately). + * Uses the shared client attach + port attach. + */ +export async function attachMerakiClientWithPorts(device, allMerakiClients, portConfigs = [], portStatusCache = new Map(), networkUrl = '') { + attachMerakiClient(device, allMerakiClients, networkUrl); + const client = device.meraki?.client; + if (client) { + await attachPortConfigAndStatus(client, portConfigs, portStatusCache); + // Merge back if needed (since we mutated client) + device.meraki.client = client; + } + return device; +} + +/** + * Build client URL dynamically from networkInfo.url to avoid hardcoding dashboard host (e.g. n976). + */ +export function buildMerakiClientUrl(clientId, networkInfo) { + if (!clientId || !networkInfo?.url) return ''; + const baseMatch = networkInfo.url.match(/^(https?:\/\/[^/]+)/); + const base = baseMatch ? baseMatch[1] : 'https://dashboard.meraki.com'; + const networkShort = networkInfo.url.match(/dashboard\.meraki\.com\/([^/]+)/i)?.[1] || ''; + const dashboardNode = networkInfo.url.match(/\/n\/([^/]+)/i)?.[1] || ''; + if (networkShort && dashboardNode) { + return `${base}/${networkShort}/n/${dashboardNode}/manage/clients/${clientId}/overview`; + } + return ''; +} + +// ====================== CHAT-ORIENTED FULL MERAKI ENRICHMENT (moved from deviceService for core sharing) ====================== +// Produces the "meraki" payload used by collectDeviceStatus (all filtered clients as devices + switches/aps maps). +// Used by core orchestrator and deviceService (thin). + +async function enrichMerakiData(merakiClientsResult, merakiPortsResult, networkInfo, switchesForStatus = null) { + const clients = Array.isArray(merakiClientsResult.value) + ? merakiClientsResult.value + : (merakiClientsResult.value?.clients || []); + + const portConfigs = Array.isArray(merakiPortsResult.value) ? merakiPortsResult.value : []; + + // Filter out VLAN 900 + const filteredClients = clients.filter(c => c.vlan !== 900 && c.vlan !== '900'); + + // Use lookup from pre-fetched switches (bulk statuses now done in shared enrichSwitchesAndAPsShared) + shared for config + // This avoids extra /ports/statuses calls. + const enrichedClients = filteredClients.map((client) => { + const portNum = client.switchport; + const switchSerial = client.recentDeviceSerial; + + const portConfig = portConfigs.find(p => + p.deviceSerial === switchSerial && + String(p.portId || p.number) === String(portNum) + ); + + let portStatus = null; + if (switchSerial && switchesForStatus && switchesForStatus.has(switchSerial)) { + const sw = switchesForStatus.get(switchSerial); + const portsList = sw?.ports || []; + portStatus = portsList.find(p => String(p.portId || p.number) === String(portNum)) || null; + } + + const merakiClientUrl = buildMerakiClientUrl(client.id, networkInfo); + + return { + ...client, + isWireless: (client.recentDeviceConnection || '').toLowerCase() === 'wireless', + ssid: client.ssid || '—', + apName: client.recentDeviceName || '—', + apSerial: client.recentDeviceSerial || '—', + merakiClientUrl, + portNumber: portNum || ( (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? '—' : '?'), + portName: portConfig?.portName || portConfig?.name || ( (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? 'Wireless (AP)' : '—'), + portEnabled: portConfig?.enabled, + speed: portConfig?.speed, + duplex: portConfig?.duplex, + poeEnabled: portConfig?.poeEnabled, + poePower: portConfig?.poePower || 0, + accessPolicy: portConfig?.accessPolicyType || portConfig?.accessPolicy || '—', + allowedMacs: portConfig?.stickyMacAllowList || portConfig?.allowedMacs || [], + + switchportStatus: portStatus, + switchportConfig: portConfig, + + switchSerial: switchSerial || '—', + deviceName: client.recentDeviceName || 'SWITCH', + connectionType: (client.recentDeviceConnection || '').toLowerCase() === 'wireless' ? 'Wireless' : 'Wired', + networkShortName: networkInfo?.url ? networkInfo.url.match(/dashboard\.meraki\.com\/([^/]+)/i)?.[1] || '' : '', + dashboardNodeId: networkInfo?.url ? networkInfo.url.match(/\/n\/([^/]+)/i)?.[1] || '' : '' + }; + }); + + return { + clients: enrichedClients, + networkInfo + }; +} + +/** + * Main Meraki enrichment for the chat/collect path (all clients + switches/APs maps). + * Now lives in enrichment for use by avEnrichmentCore and deviceService. + */ +export async function enrichAllMerakiData(networkId, merakiClientsResult, merakiPortsResult, networkInfo = null) { + // Use passed networkInfo if provided (from collectDeviceStatus which has storeNum) + // Falls back only for direct calls (no more dummy "2477") + if (!networkInfo || !networkInfo.url) { + networkInfo = { url: '' }; + try { + // Fallback lookup (caller should pass to avoid) + const net = await findMerakiNetwork(""); + if (net?.url) networkInfo.url = net.url; + } catch (e) { + logger('device:service', 'Could not get networkInfo for client links', 'warn'); + } + } + + // Switches/APs now use shared (computes relevant serials from *raw* clients directly, fetches only needed details+statuses+wireless). + // This + prior relevantSwitchSerials for ports + relevant device details = big reduction in Meraki calls (key to 429 fixes). + const clientsRaw = Array.isArray(merakiClientsResult.value) ? merakiClientsResult.value : (merakiClientsResult.value?.clients || []); + const portConfigs = Array.isArray(merakiPortsResult.value) ? merakiPortsResult.value : []; + + const { switches, aps } = await enrichSwitchesAndAPsShared(networkId, clientsRaw); + + // Step 1 (reordered): Client enrichment, with status lookup from switches map (no extra status fetch) + const clientEnriched = await enrichMerakiData( + merakiClientsResult, + merakiPortsResult, + networkInfo, + switches // pass for status lookup to avoid dupe fetches + ); + + // Step 3: Build final devices with enriched AP/Switch data + const enrichedDevices = (clientEnriched.clients || []).map(client => { + const m = client || {}; + const device = { + id: `D${(client.description || client.user || client.mac || '').replace(/[^a-zA-Z0-9]/g, '')}`, + name: client.description || client.user || client.mac || 'Unknown', + meraki: { ...m }, + red: client.red || {}, + optisigns: client.optisigns || {}, + atlas: null + }; + + if (m.apSerial && aps.has(m.apSerial)) { + device.apDetails = aps.get(m.apSerial); + device.meraki.apEnriched = true; + } + + if (m.switchSerial && switches.has(m.switchSerial)) { + device.switchDetails = switches.get(m.switchSerial); + device.meraki.switchEnriched = true; + } + + return device; + }); + + return { + devices: enrichedDevices, + switches: Array.from(switches.values()), + aps: Array.from(aps.values()) + }; +} + +export default { + attachMerakiClient, + enrichWirelessDetails, + enrichMerakiDeviceDetails, + enrichSwitchesAndAPsShared, + enrichAllMerakiData, + attachPortConfigAndStatus, + attachMerakiClientWithPorts, + buildMerakiClientUrl, +}; diff --git a/services/enrichment/merakiMatcher.js b/services/enrichment/merakiMatcher.js new file mode 100644 index 0000000..839bfd5 --- /dev/null +++ b/services/enrichment/merakiMatcher.js @@ -0,0 +1,184 @@ +// services/enrichment/merakiMatcher.js +// +// Unified, high-quality Meraki client matching logic. +// This is the single source of truth for matching MDM/Atlas devices +// to Meraki clients across the entire application. +// +// Extracted and unified from multiple locations (avDeviceBuilder, deviceService, etc.) +// to eliminate duplication and make improvements in one place. + +import { logger } from '../../utils/logger.js'; +import { normalizePlayerName } from '../../utils/normalize.js'; +import { normalizeMac } from './normalizers.js'; + +/** + * Finds the best matching Meraki client for a given device (MDM or Atlas). + * Uses multiple strategies in priority order for maximum reliability, + * especially with country-prefixed identifiers (US, CA, etc.). + * + * @param {Object} device - The source device (from MDM or Atlas) + * @param {Array} allMerakiClients - List of Meraki clients + * @param {Object} [options] + * @param {boolean} [options.debug] - Enable extra debug logging for a specific device + * @returns {Object|null} The best matching Meraki client, or null + */ +export function findBestMerakiClientMatch(device, allMerakiClients, options = {}) { + if (!device || !allMerakiClients || allMerakiClients.length === 0) { + return null; + } + + const identifier = (device.identifier || '').toUpperCase().trim(); + const username = ( + device.mdmData?.UserName || + device.mdmData?.DeviceFriendlyName || + device.mdmData?.DeviceReportedName || + '' + ).toUpperCase().trim(); + + // Extract country prefix if present (US, CA, MX, EU, AU, ...) + const countryMatch = identifier.match(/^(US|CA|MX|EU|AU)/i); + const countryPrefix = countryMatch ? countryMatch[1].toUpperCase() : 'US'; + + // Stripped version without country prefix (e.g. 001024MSCAE) + const strippedId = identifier.replace(/^(US|CA|MX|EU|AU)/i, '').trim(); + + // Atlas support: extract IP/MAC from atlasData or summary (for AMPs in rich/build paths) + // This allows IP-based matching when name/desc doesn't match (common for AMPs) + const atlasIp = ( + device.atlasData?.state?.IpAddress || + device.atlasData?.ipAddress || + device.atlasDataSummary?.ipAddress || + device.ip || + '' + ).trim().toLowerCase(); + + const atlasMac = normalizeMac( + device.atlasData?.mac || + device.atlasDataSummary?.macAddress || + '' + ); + + const shouldDebug = options.debug || identifier.startsWith('CA'); + + if (shouldDebug) { + logger('enrichment:meraki', `Matching attempt for ${identifier}`); + logger('enrichment:meraki', `Username: ${username}, Stripped: ${strippedId}, atlasIp: ${atlasIp}, atlasMac: ${atlasMac}`); + } + + // Track the best (most recent by lastSeen) match across *all* strategies. + // This handles the case of multiple client entries for the same AV identifier (e.g. historical + // connections on different ports/APs over the timespan window). We return the freshest one. + let bestMatch = null; + let bestTime = -1; + + for (const listItem of allMerakiClients) { + // Unwrap: in chat shape, the "clients" list items from enrichAll are wrapped {name, meraki: {...client data...}, ...} + // In rich shape, they are raw client objects with description/user/mac/ip at top. + // Support both so matching works uniformly for MDM and Atlas in both paths. + const c = listItem.meraki || listItem; // the inner client data if wrapped + const desc = (c.description || listItem.name || '').toUpperCase().trim(); + const userField = (c.user || '').toUpperCase().trim(); + const clientMac = normalizeMac(c.mac); + + let matchedThis = false; + + // Strategy 1: Exact full identifier match (best case) + if (desc === identifier || userField === identifier) { + matchedThis = true; + if (shouldDebug) logger('enrichment:meraki', `✓ Exact match for ${identifier}`); + } + + // Strategy 2: Username / friendly name match + if (username && (desc.includes(username) || userField.includes(username))) { + matchedThis = true; + if (shouldDebug) logger('enrichment:meraki', `✓ Username match for ${identifier}`); + } + + // Strategy 3: Stripped ID match (handles CA001024MSCAE vs 001024MSCAE) + if (strippedId && (desc.includes(strippedId) || userField.includes(strippedId))) { + matchedThis = true; + if (shouldDebug) logger('enrichment:meraki', `✓ Stripped ID match for ${identifier}`); + } + + // Strategy 4: Country + stripped fallback + if (countryPrefix && strippedId) { + const countryStripped = `${countryPrefix}${strippedId}`; + if (desc.includes(countryStripped) || userField.includes(countryStripped)) { + matchedThis = true; + if (shouldDebug) logger('enrichment:meraki', `✓ Country+stripped match for ${identifier}`); + } + } + + // Strategy 5: MAC address fallback (very reliable) + // Support various shapes: mdmData.MacAddress (AV), direct .mac or .MacAddress (phones, Webex, etc.) + // Also atlas for AMPs (from atlasData or summary in base prepared devices) + const deviceMac = device.mdmData?.MacAddress || device.mac || device.MacAddress || atlasMac; + if (deviceMac) { + const devMac = normalizeMac(deviceMac); + if (clientMac && clientMac === devMac) { + matchedThis = true; + if (shouldDebug) logger('enrichment:meraki', `✓ MAC match for ${identifier}`); + } + } + + // Strategy 6: IP address match (for Atlas AMPs that may not expose friendly name in Meraki clients) + if (atlasIp) { + const clientIp = (c.ip || '').trim().toLowerCase(); + if (clientIp && clientIp === atlasIp) { + matchedThis = true; + if (shouldDebug) logger('enrichment:meraki', `✓ IP match for ${identifier}`); + } + } + + if (matchedThis) { + // Pick the most recent by lastSeen (Meraki provides lastSeen on client records). + // This ensures that when multiple entries exist for the same device (different ports/sessions), + // we attach the current/freshest one (e.g. the one on port 35 for VW01). + const thisTime = c.lastSeen ? Date.parse(c.lastSeen) || 0 : 0; + if (bestMatch === null || thisTime > bestTime) { + bestMatch = listItem; + bestTime = thisTime; + } + // Continue scanning to find any even fresher match via other strategies or later records. + } + } + + if (bestMatch) { + const chosen = bestMatch.meraki || bestMatch; + if (shouldDebug || identifier.includes('2477')) { + logger('enrichment:meraki', `✓ Most recent Meraki client chosen for ${identifier} (lastSeen=${chosen.lastSeen || 'n/a'}, port=${chosen.switchport || chosen.recentDevicePort || 'n/a'})`); + } + return bestMatch; // return the list item (wrapped or raw) for consistent downstream handling + } + + if (shouldDebug) { + logger('enrichment:meraki', `✗ No match found for ${identifier}`, 'debug'); + } + + return null; +} + +/** + * Convenience wrapper that also enriches the device object in place + * (for backward compatibility during migration). + */ +export async function enrichDeviceWithBestMerakiMatch(device, allMerakiClients, portStatusCache) { + const match = findBestMerakiClientMatch(device, allMerakiClients); + + if (!match) { + return device; + } + + // Basic enrichment (can be extended later) + device.meraki = { + ...(device.meraki || {}), + client: match, + connectionType: + match.recentDeviceConnection === 'Wireless' || match.ssid ? 'Wireless' : 'Wired', + mac: match.mac, + ip: match.ip, + // Note: port enrichment can be added here or kept separate + }; + + return device; +} diff --git a/services/enrichment/normalizers.js b/services/enrichment/normalizers.js new file mode 100644 index 0000000..4c71ca4 --- /dev/null +++ b/services/enrichment/normalizers.js @@ -0,0 +1,60 @@ +// services/enrichment/normalizers.js +// +// Shared normalization helpers for AV device enrichment (MDM, RED, OptiSigns, Atlas, etc.). +// Central place for name/MAC/RED-ID canonicalization so we don't have 4-5 copies. +// +// Re-exports normalizePlayerName from utils for convenience in the enrichment context. +// Extracted during Option A consolidation. + +// Re-export the core player name normalizer for AV enrichment consumers. +export { normalizePlayerName } from '../../utils/normalize.js'; + +/** + * Normalize a MAC address for comparison (strip separators, lowercase). + * Used across Meraki matching, phone service fallbacks, etc. + */ +export const normalizeMac = (mac) => + mac ? String(mac).toLowerCase().replace(/[:.-]/g, '') : ''; + +/** + * Translate a RED DeviceID (e.g. "US.OFFLINE.2477" or "US.AE.1234") + * into the canonical MDM-style identifier used for matching + * (e.g. "US002477MSCOFF" or "US001234MSCAE"). + * + * This is the inverse direction of some name normalizations and is + * critical for stores using "OFFLINE", "AE", "AERIE" branded RED players. + * + * Moved from avDeviceBuilder during consolidation. + */ +export function translateREDDeviceID(deviceID) { + if (!deviceID) return null; + + const parts = String(deviceID).trim().toUpperCase().split('.'); + if (parts.length < 3) return null; + + const country = parts[0]; // US, CA, MX, etc. + const brand = parts[1]; // OFFLINE, AE, AERIE, ... + const store = parts[2]; // 2477, 3876, etc. + + // Pad store number to 6 digits (e.g. 2477 → 002477) + const paddedStore = store.padStart(6, '0'); + + // Brand mapping (kept identical to original for zero behavior change) + let brandSuffix = ''; + switch (brand) { + case 'OFFLINE': + brandSuffix = 'MSCOFF'; + break; + case 'AE': + brandSuffix = 'MSCAE'; + break; + case 'AERIE': + brandSuffix = 'MSCAERIE'; + break; + default: + brandSuffix = `MSC${brand}`; // fallback for unknown brands + } + + // Return full identifier with correct country prefix + return `${country}${paddedStore}${brandSuffix}`; +} diff --git a/services/enrichment/optisignsMatcher.js b/services/enrichment/optisignsMatcher.js new file mode 100644 index 0000000..1c8ec8a --- /dev/null +++ b/services/enrichment/optisignsMatcher.js @@ -0,0 +1,97 @@ +// services/enrichment/optisignsMatcher.js +// +// Shared OptiSigns device matching and enrichment helpers for AV. +// This centralizes the logic previously duplicated in deviceService, avDeviceBuilder, and avEnricher. +// +// - findBestOptiSignsMatch: unified finder (prefers normalized exact match for consistency, +// with loose contains fallback for compatibility). +// - createOptiSignsDisplay: produces the slim {content, lastHeartBeat, isOld} shape used by chat (/avstatus) and HTML. +// - createRichOptiSigns: produces the rich shape used by /build and modals (spreads raw + resolved names). +// +// Uses normalizePlayerName from normalizers and the resolution helpers from the OptiSigns client. +// Extracted as part of Option A incremental consolidation (after RED). + +import { normalizePlayerName } from './normalizers.js'; +import { getPlaylistName, getAssetName } from '../../integrations/optisigns/client.js'; + +/** + * Find the best matching OptiSigns device for a given AV/MDM identifier (e.g. friendlyName or device.identifier + * containing VW/LED). + * + * @param {string} identifier + * @param {Array} optiList - list of raw devices from getOptiSignStatus().devices + * @returns {Object|null} the matching raw OptiSigns device or null + */ +export function findBestOptiSignsMatch(identifier, optiList) { + if (!identifier || !Array.isArray(optiList) || optiList.length === 0) { + return null; + } + + const normId = normalizePlayerName(identifier).toLowerCase().trim(); + if (!normId) return null; + + // Strategy 1: Exact normalized match on deviceName (preferred; matches deviceService and avEnricher logic) + let match = optiList.find(o => + normalizePlayerName(o.deviceName || '').toLowerCase().trim() === normId + ); + if (match) { + return match; + } + + // Strategy 2: Loose contains fallback using original identifier (preserves builder's previous heuristic + // for cases with extra text in names; uses upper for broad match) + const idUpper = identifier.toUpperCase().trim(); + if (idUpper) { + match = optiList.find(d => + (d.deviceName || '').toUpperCase().includes(idUpper) + ); + if (match) { + return match; + } + } + + return null; +} + +/** + * Build the slim OptiSigns display object used by chat commands (/avstatus) and HTML views. + * (Logic moved from deviceService for sharing.) + */ +export function createOptiSignsDisplay(rawOpti) { + if (!rawOpti) return null; + + let content = 'Idle'; + if (rawOpti.currentType === 'playlist' && rawOpti.currentPlaylistId) { + content = `Playing "${getPlaylistName(rawOpti.currentPlaylistId)}"`; + } else if (rawOpti.currentType === 'asset' && rawOpti.currentAssetId) { + content = `Showing "${getAssetName(rawOpti.currentAssetId)}"`; + } else if (rawOpti.currentType) { + content = rawOpti.currentType; + } + + const hoursSinceHeartbeat = rawOpti.lastHeartBeat + ? (Date.now() - new Date(rawOpti.lastHeartBeat).getTime()) / (1000 * 60 * 60) + : 999; + + return { + content, + lastHeartBeat: rawOpti.lastHeartBeat, + isOld: hoursSinceHeartbeat > 24 + }; +} + +/** + * Build the rich OptiSigns object used by the build endpoint and modals. + * Spreads the raw + resolves current names/ids for display (logic moved from avDeviceBuilder). + */ +export function createRichOptiSigns(rawOpti) { + if (!rawOpti) return null; + + return { + ...rawOpti, + currentPlaylistName: getPlaylistName(rawOpti.currentPlaylistId), + currentAssetName: rawOpti.currentAssetId ? getAssetName(rawOpti.currentAssetId) : null, + currentPlaylistId: rawOpti.currentPlaylistId, + currentAssetId: rawOpti.currentAssetId + }; +} diff --git a/services/enrichment/redMatcher.js b/services/enrichment/redMatcher.js new file mode 100644 index 0000000..8cb01d5 --- /dev/null +++ b/services/enrichment/redMatcher.js @@ -0,0 +1,98 @@ +// services/enrichment/redMatcher.js +// +// Unified RED player matching for AV enrichment. +// This is the single source of truth for finding which RED player (if any) +// corresponds to a given MDM/AV device (mainly the MSC* ones). +// +// Consolidates logic previously duplicated in: +// - deviceService.js (enrichMdmDevices, with special ae/offline/aerie cases) +// - avDeviceBuilder.js (enrichDomainData, with translate + includes) +// - avEnricher.js (findRedMatch, which was weaker for dotted DeviceIDs) +// +// Uses translateREDDeviceID (and normalizePlayerName via barrel) from normalizers. +// Extracted as part of Option A incremental consolidation. + +import { normalizePlayerName, translateREDDeviceID } from './normalizers.js'; + +/** + * Find the best matching full RED player object for a source identifier. + * Identifier can be a friendlyName, device.identifier, etc. (e.g. "US002477MSCOFF" + * or "US002477MSCAE"). + * + * Handles RED DeviceIDs in formats like: + * - "US.OFFLINE.2477" -> matches US002477MSCOFF devices + * - "US.AE.1234" -> matches US001234MSCAE + * - "US.AERIE.999" + * - Also falls back to Name field contains, and direct matches. + * + * @param {string} identifier + * @param {Array} redList - raw players from getREDStatusForStore (full objects) + * @returns {Object|null} matching RED player (full data, including AvailabilityStatus etc.) or null + */ +export function findBestRedMatch(identifier, redList) { + if (!identifier || !Array.isArray(redList) || redList.length === 0) { + return null; + } + + // Canonical form for the AV/MDM side (e.g. US002477MSCOFF) + const idUpper = normalizePlayerName(identifier).toUpperCase().trim(); + if (!idUpper) return null; + + // Helper: does hay contain id as a "whole" token (not as prefix of longer brand like MSCAE inside MSCAERIE) + function containsWhole(id, hay) { + if (!hay || !id) return false; + let idx = -1; + while ((idx = hay.indexOf(id, idx + 1)) !== -1) { + const after = hay[idx + id.length]; + if (after === undefined || !/[A-Z0-9]/.test(after)) { + return true; + } + } + return false; + } + + // Strategy 1 (preferred for translated cases): Translate RED DeviceID using the canonical translator and exact match. + // This is the reliable path for OFFLINE/AE/AERIE branded players (US.OFFLINE.2477 etc.) + // and covers all the previous ad-hoc special cases in deviceService. + for (const redPlayer of redList) { + const translated = translateREDDeviceID(redPlayer.DeviceID); + if (translated && translated.toUpperCase() === idUpper) { + return redPlayer; + } + } + + // Strategy 2: Direct "whole token" match against RED's DeviceID or Name (preserves original builder heuristic, + // but avoids false positives on overlapping suffixes like MSCAE vs MSCAERIE). + let match = redList.find(p => { + const did = (p.DeviceID || '').toUpperCase(); + const nm = (p.Name || '').toUpperCase(); + return containsWhole(idUpper, did) || containsWhole(idUpper, nm); + }); + if (match) { + return match; + } + + // Strategy 3: Fallback using normalizePlayerName on the RED side too (exact after norm). + // This preserves (and may slightly improve) the previous avEnricher findRedMatch behavior + // for RED records that are already in a "clean" form. For dotted DeviceIDs this alone + // would have missed before (normalize on "US.OFFLINE.2477" gives "US002477"), but + // Strategy 2 will have caught it. + const targetLower = idUpper.toLowerCase(); + match = redList.find(r => { + const fromRed = normalizePlayerName(r.DeviceID || r.Name || '').toLowerCase().trim(); + return fromRed === targetLower; + }); + if (match) { + return match; + } + + return null; +} + +/** + * Backward-compat alias (used internally by avEnricher etc. during migration). + * New code should prefer findBestRedMatch. + */ +export function findRedMatch(name, redList) { + return findBestRedMatch(name, redList); +} diff --git a/services/jiraPollerService.js b/services/jiraPollerService.js new file mode 100644 index 0000000..8b3ba2f --- /dev/null +++ b/services/jiraPollerService.js @@ -0,0 +1,369 @@ +// src/services/jiraPollerService.js +// +// Hourly Jira poller. +// +// Fetches unassigned tickets in the AV / Communication Services / Mobility +// queue, enriches each store-scoped ticket with a compact phone or AV +// status snapshot posted as a Jira comment, labels the ticket +// `bot-enriched` so it's not re-processed on subsequent polls, and posts +// a summary of newly-enriched tickets to a configured Webex space. +// +// Idempotency model — a Jira label is the source of truth. The JQL +// includes `AND labels != bot-enriched`, so Jira itself only returns +// unseen tickets. This survives bot restarts, deploys, and (harmlessly) +// concurrent runs — no local state file, no in-memory cursor. +// +// Store number handling — the poller looks up the `Store Number` custom +// field id via `JiraClient.getFieldIdByName()` (cached for process +// lifetime) or a `JIRA_STORE_FIELD_ID` env override. If a ticket has no +// store value, it's skipped entirely — no comment, no label, no summary +// line — so future non-store enrichment tooling can pick it up later. +// +// Comment format — Jira Cloud v3 requires ADF. We emit an italic +// header paragraph followed by the same markdown the /phonestatus or +// /avstatus chat command would produce, converted to ADF paragraphs +// via utils/markdownToAdf. This preserves clickable Meraki links, +// bold device names, and paragraph structure that the previous +// code-block format flattened to unformatted text. +// +// Failure isolation — every per-ticket step is in a per-ticket +// try/catch. One flaky ticket cannot stop the batch. The label is only +// added *after* `addComment` succeeds, so a transient Jira 5xx on the +// comment retries next hour rather than silently swallowing the ticket. + +import { logger } from '../utils/logger.js'; +import jira from '../integrations/jira/JiraClient.js'; +import botClient from '../integrations/webex/BotClient.js'; +import { collectPhoneStatus } from './phoneService.js'; +import { collectDeviceStatus } from './deviceService.js'; +import { classifyTicket, TicketClassifierError } from './ticketClassifier.js'; +import { adfToPlainText } from '../utils/adfToPlainText.js'; +import { markdownToAdfContent } from '../utils/markdownToAdf.js'; +import { buildAdfComment } from '../utils/adfComment.js'; +import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js'; +import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js'; + +const BOT_LABEL = 'bot-enriched'; +const STORE_FIELD_NAME = 'Store Number'; + +// Hard safety cap on tickets processed per poll. AI classification +// costs money AND rate-limit budget per call, so a runaway (mass ticket +// import, JQL change that suddenly matches thousands of rows) should +// not translate into an unbounded X.AI bill in a single hour. Backlog +// drains at MAX_TICKETS_PER_POLL/hour once it exists. +const MAX_TICKETS_PER_POLL = 50; + +// Maps classifier's `kind` to the enrichment collector. Replaces the +// component-name -> collector map that used to be the source of truth +// pre-AI. The classifier's output space is closed (phone|av|skip), so +// this map only needs the two enrichable kinds — 'skip' short-circuits +// before we get here. +const KIND_TO_COLLECTOR = { + phone: collectPhoneStatus, + av: collectDeviceStatus, +}; + +// Component name -> enrichment collector. Strict mapping per plan; a +// ticket whose components don't match any key here is skipped (though +// the poller's JQL should ensure this is never actually hit). +export const COMPONENT_ROUTES = { + 'Communication Services': { kind: 'phone', collect: collectPhoneStatus }, + 'Mobility': { kind: 'phone', collect: collectPhoneStatus }, + 'Audio Visual': { kind: 'av', collect: collectDeviceStatus }, +}; + +// The JQL kept as a single owned constant so it's obvious in one place +// and easy to audit against the spec. Any status/component change lives +// here. +// +// Label clause gotcha: JQL's `!=` operator excludes issues where the +// field is empty (documented Atlassian behavior), and brand-new tickets +// almost always have zero labels. A naive `labels != bot-enriched` +// therefore filters out precisely the tickets we want. The +// `IS EMPTY OR ... != ...` union is the standard workaround — it +// matches "no labels at all" plus "has labels, none of them are +// bot-enriched". Do NOT "simplify" this back to a bare `!=`. +export const POLLER_JQL = [ + 'component IN ("Communication Services", "Audio Visual", Mobility)', + 'AND assignee = empty', + 'AND status IN ("Assign to Team", "Equipment Sent", Escalated, "High Severity Incident",', + ' "In Progress", "New Request", "Not Started", Open, Pending, "Work in progress")', + `AND (labels IS EMPTY OR labels != "${BOT_LABEL}")`, +].join(' '); + +// Resolve the Store Number field id. Env override wins so an operator +// can pin it during Jira schema experiments. Otherwise cached inside +// JiraClient after the first successful discovery. On error, clears the +// memoization so the next poll retries. +let _storeFieldIdPromise = null; +async function resolveStoreFieldId() { + const override = process.env.JIRA_STORE_FIELD_ID; + if (override) return override; + if (!_storeFieldIdPromise) { + _storeFieldIdPromise = jira.getFieldIdByName(STORE_FIELD_NAME).catch((err) => { + _storeFieldIdPromise = null; + throw err; + }); + } + return _storeFieldIdPromise; +} + +// Pick the first component whose name we recognize. Jira allows a +// ticket to have multiple components; we honor the first match rather +// than trying to blend two enrichment kinds. +export function routeForTicket(components) { + for (const c of components || []) { + const route = COMPONENT_ROUTES[c?.name]; + if (route) return route; + } + return null; +} + +// Custom fields can return strings, numbers, `{value}` objects, or +// nulls depending on the field configuration. Accept the simplest cases +// and require a 2-6 digit numeric value so we don't confuse "N/A" or +// "unknown" text with a real store. +export function extractStore(fieldValue) { + if (fieldValue === null || fieldValue === undefined) return null; + const raw = typeof fieldValue === 'object' + ? (fieldValue.value ?? fieldValue.name ?? '') + : fieldValue; + const s = String(raw).trim(); + const m = s.match(/^\d{2,6}$/); + return m ? m[0] : null; +} + +// Re-export the extracted `buildAdfComment` helper so any existing +// callers that pulled it from this module keep working. Actual body +// lives in utils/adfComment.js — pure, side-effect-free, testable in +// isolation from the Jira / Webex clients this service imports. +export { buildAdfComment }; + +/** + * Poll Jira for unassigned tickets in the AV / Comm / Mobility queue, + * enrich store-scoped ones with a phone/av snapshot comment, label + * processed tickets `bot-enriched`, and post a summary to Webex. + * + * @param {object} [opts] + * @param {boolean} [opts.prime=false] If true, label every matching + * ticket as `bot-enriched` WITHOUT enriching or notifying. Used for + * a one-time backlog prime pass via JIRA_POLLER_PRIME_ON_START=true. + * @returns {Promise<{enriched: number, skipped: number, primed?: number}>} + */ +export async function pollNewTickets({ prime = false } = {}) { + const startedAt = Date.now(); + logger('jira:poller', `Poll starting${prime ? ' (PRIME mode — labels only, no enrichment/notify)' : ''}`); + + let storeFieldId; + try { + storeFieldId = await resolveStoreFieldId(); + } catch (err) { + logger('jira:poller', `Aborting poll — could not resolve Store Number field id: ${err.message}`, 'error'); + return { enriched: 0, skipped: 0 }; + } + if (!storeFieldId) { + logger('jira:poller', `Aborting poll — Jira field '${STORE_FIELD_NAME}' not found; set JIRA_STORE_FIELD_ID to override`, 'error'); + return { enriched: 0, skipped: 0 }; + } + + // Explicitly ask for the store custom field — the default fields list + // in JiraClient.search() doesn't include it, so without this every + // ticket would look store-less. `description` and `reporter` are + // pulled in for the AI classifier's context payload; both are needed + // per-ticket so we ask up front rather than fetching per-issue. + const fields = [ + 'key', 'summary', 'description', 'status', 'components', + 'assignee', 'reporter', 'created', storeFieldId, + ].join(','); + + // Emit the effective JQL + store field id every poll so operators can + // paste the exact string into Jira's advanced-search UI to compare + // what the bot sees vs what a human sees. Silent "0 results" from a + // misconfigured component name or a service-account visibility gap + // is otherwise near-impossible to diagnose. + logger('jira:poller', `Executing search — storeFieldId=${storeFieldId}, JQL=${POLLER_JQL}`); + + let searchResult; + try { + searchResult = await jira.search(POLLER_JQL, fields); + } catch (err) { + logger('jira:poller', `Aborting poll — Jira search failed: ${err.message}`, 'error'); + return { enriched: 0, skipped: 0 }; + } + + let issues = Array.isArray(searchResult?.issues) ? searchResult.issues : []; + logger('jira:poller', `Search returned ${issues.length} unlabeled candidate ticket(s)`); + + if (issues.length === 0) { + logger('jira:poller', `Poll complete in ${((Date.now() - startedAt) / 1000).toFixed(1)}s — nothing new`); + return { enriched: 0, skipped: 0 }; + } + + // Cap enforcement — anything past MAX_TICKETS_PER_POLL waits for + // next hour. Deliberately NOT sampled (first-N slice) so operators + // can predict which tickets the poller will attempt each hour; the + // cap is a safety net, not a load-balancer. + if (issues.length > MAX_TICKETS_PER_POLL) { + logger( + 'jira:poller', + `Ticket count ${issues.length} exceeds MAX_TICKETS_PER_POLL=${MAX_TICKETS_PER_POLL} — ` + + `processing first ${MAX_TICKETS_PER_POLL}, remaining ${issues.length - MAX_TICKETS_PER_POLL} will be picked up next poll`, + 'warn' + ); + issues = issues.slice(0, MAX_TICKETS_PER_POLL); + } + + if (prime) { + let primed = 0; + for (const issue of issues) { + try { + await jira.addLabel(issue.key, BOT_LABEL); + primed++; + } catch (err) { + logger('jira:poller', `PRIME: failed to label ${issue.key}: ${err.message}`, 'warn'); + } + } + const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); + logger('jira:poller', `PRIME complete — labeled ${primed}/${issues.length} tickets as '${BOT_LABEL}' in ${elapsedSec}s`); + return { enriched: 0, skipped: issues.length - primed, primed }; + } + + const enriched = []; // { key, summary, storeNum, kind, reason } + const skipped = []; // { key, reason } + let totalTokens = 0; + for (const issue of issues) { + const key = issue.key; + const f = issue.fields || {}; + const summary = f.summary || '(no summary)'; + + // Jira Cloud v3 returns `description` as an ADF document + // (structured JSON), not plain text. Flatten it before handing to + // the classifier — otherwise we'd pay tokens for JSON syntax the + // model has to parse itself. The `adfToPlainText` helper is the + // same one the summarizer uses on ticket bodies. + const descriptionText = f.description + ? (typeof f.description === 'string' ? f.description : adfToPlainText(f.description)) + : ''; + + // Build the classifier payload. Components / raw Store Number + // field value are included as *hints* — the classifier is free to + // ignore them if the summary/description tell a different story. + const ticketPayload = { + key, + summary, + description: descriptionText, + components: f.components || [], + status: f.status?.name, + reporter: f.reporter?.displayName || f.reporter?.emailAddress, + storeFieldRaw: f[storeFieldId], + }; + + let classification; + try { + classification = await classifyTicket(ticketPayload); + totalTokens += classification.tokensUsed || 0; + } catch (err) { + // Skip-until-recovery per the classifier plan: AI failure means + // the ticket waits for next hour rather than falling back to a + // stale component-based decision. + const label = err instanceof TicketClassifierError ? 'AI classification failed' : 'unexpected classifier error'; + logger('jira:poller', `${key}: SKIP — ${label}: ${err.message}`, 'warn'); + skipped.push({ key, reason: label }); + continue; + } + + if (classification.kind === 'skip' || !classification.storeNum) { + logger('jira:poller', `${key}: SKIP — AI: ${classification.reason}`); + skipped.push({ key, reason: classification.reason }); + continue; + } + + const collect = KIND_TO_COLLECTOR[classification.kind]; + if (!collect) { + // Belt-and-suspenders: parseAndValidate already gates kind to + // phone|av|skip, but if the schema ever loosens we don't want to + // silently no-op. + logger('jira:poller', `${key}: SKIP — no collector for kind '${classification.kind}'`, 'warn'); + skipped.push({ key, reason: `unsupported kind: ${classification.kind}` }); + continue; + } + + try { + logger('jira:poller', `${key}: enriching (${classification.kind}, store ${classification.storeNum}) — AI: ${classification.reason}`); + const data = await collect(classification.storeNum); + + // Same markdown the chat commands emit. `footer: false` strips + // the "*Last checked: HH:MM*" line — a Jira comment already has + // an authoritative timestamp in the header paragraph below and + // Jira's own `created` field. Detailed mode always on for Jira + // so triagers get the richest possible per-device info. + const markdown = classification.kind === 'phone' + ? renderPhoneStatusMarkdown(data, { + storeNum: classification.storeNum, + detailed: true, + footer: false, + }) + : renderAvStatusMarkdown(data, { + storeNum: classification.storeNum, + detailed: true, + footer: false, + }); + + const headerLine = + `Auto-enriched by CollabFinder — ${classification.kind} snapshot for store ${classification.storeNum} ` + + `at ${new Date().toISOString()} · AI: ${classification.reason}`; + const adf = buildAdfComment({ + headerLine, + bodyNodes: markdownToAdfContent(markdown), + }); + + await jira.addComment(key, adf); + await jira.addLabel(key, BOT_LABEL); + + enriched.push({ + key, + summary, + storeNum: classification.storeNum, + kind: classification.kind, + reason: classification.reason, + }); + } catch (err) { + logger('jira:poller', `${key}: enrichment failed — ${err.message}`, 'error'); + skipped.push({ key, reason: `error: ${err.message}` }); + } + } + + const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); + logger('jira:poller', `Poll complete in ${elapsedSec}s — enriched ${enriched.length}, skipped ${skipped.length}, tokens ${totalTokens}`); + + // Summary post — only when there's something worth reporting AND a + // target room is configured. Skipped-only polls stay silent to avoid + // spamming the space every hour. The AI's reason is included per + // ticket so a human can spot-check misclassifications at a glance + // (this is the "compensating control" for full-auto mode). + const roomId = process.env.JIRA_POLLER_ROOM_ID; + if (enriched.length > 0 && roomId) { + const lines = [ + `**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`, + '', + ...enriched.map((t) => + `• **${t.key}** [${t.kind === 'phone' ? 'Phone' : 'AV'}, store ${t.storeNum}] — ${t.summary}\n` + + ` _AI: ${t.reason}_`, + ), + ]; + if (skipped.length > 0) { + lines.push(''); + lines.push( + `_${skipped.length} ticket(s) skipped: ` + + `${skipped.map((s) => `${s.key} (${s.reason})`).join(', ')}_`, + ); + } + try { + await botClient.sendMarkdown(roomId, lines.join('\n')); + } catch (err) { + logger('jira:poller', `Failed to post summary to Webex: ${err.message}`, 'warn'); + } + } + + return { enriched: enriched.length, skipped: skipped.length, tokensUsed: totalTokens }; +} diff --git a/services/jiraService.js b/services/jiraService.js new file mode 100644 index 0000000..d2b386c --- /dev/null +++ b/services/jiraService.js @@ -0,0 +1,103 @@ +// src/services/jiraService.js +import jira from '../integrations/jira/JiraClient.js'; +import { logger } from '../utils/logger.js'; + +const componentMap = { + av: 'Audio Visual', + audio: 'Audio Visual', + visual: 'Audio Visual', + voice: 'Communication Services', + phone: 'Communication Services', + phones: 'Communication Services', + telephony: 'Communication Services', + comm: 'Communication Services', + mobility: 'Mobility', + mobile: 'Mobility', + wireless: 'Mobility', +}; + +const projects = ['SUPPORT', 'SS']; + +// ==================== STORE + COMPONENT ==================== +export async function getJiraTicketsForComponentWithStore(storeNumber, componentInput) { + if (!storeNumber || !componentInput) return []; + + const normalized = componentInput.toLowerCase().trim(); + const componentName = componentMap[normalized] || componentInput; + const paddedStore = storeNumber.toString().padStart(5, '0'); + + const projectClause = projects.map(p => `project = ${p}`).join(' OR '); + + const jql = `(${projectClause}) + AND "Store Number" = "${paddedStore}" + AND component = "${componentName}" + ORDER BY updated DESC`; + + try { + const result = await jira.search(jql, 'key,summary,status,resolution,assignee,created,resolved,components', 20); + return result.issues || []; + } catch (err) { + logger('jira:service', `Failed store+component search`, 'warn'); + return []; + } +} + +// ==================== STORE ONLY ==================== +export async function getJiraTicketsForStore(storeNumber) { + if (!storeNumber) return []; + + const paddedStore = storeNumber.toString().padStart(5, '0'); + const projectClause = projects.map(p => `project = ${p}`).join(' OR '); + + const jql = `(${projectClause}) AND "Store Number" = "${paddedStore}" ORDER BY updated DESC`; + + logger('jira:service', `Searching store ${paddedStore} with JQL: ${jql}`, 'debug'); + + try { + const result = await jira.search(jql, 'key,summary,status,resolution,assignee,created,resolved,components', 30); + logger('jira:service', `Found ${result.issues?.length || 0} tickets for store ${paddedStore}`, 'debug'); + return result.issues || []; + } catch (err) { + logger('jira:service', `Failed store search for ${paddedStore}: ${err.message}`, 'error'); + return []; + } +} + +// ==================== COMPONENT ONLY ==================== +export async function getJiraTicketsForComponent(componentInput) { + if (!componentInput) return []; + + const normalized = componentInput.toLowerCase().trim(); + const componentName = componentMap[normalized] || componentInput; + + const projectClause = projects.map(p => `project = ${p}`).join(' OR '); + + const jql = `(${projectClause}) AND component = "${componentName}" ORDER BY updated DESC`; + + try { + const result = await jira.search(jql, 'key,summary,status,resolution,assignee,created,resolved,components', 15); + return result.issues || []; + } catch (err) { + logger('jira:service', `Failed component search`, 'error'); + return []; + } +} + +// ==================== SHARED HELPERS (for commands consistency) ==================== + +export function getStatusEmoji(status) { + const s = (status || '').toLowerCase(); + if (s.includes('resolved') || s.includes('done') || s.includes('fixed')) return '✅'; + if (s.includes('in progress') || s.includes('open')) return '🔄'; + if (s.includes('pending')) return '⏳'; + return '📌'; +} + +export function calculateDaysOpen(created, resolvedOrUpdated) { + if (!created) return '—'; + const start = new Date(created); + const end = resolvedOrUpdated ? new Date(resolvedOrUpdated) : new Date(); + const diffTime = Math.abs(end - start); + const days = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return isNaN(days) ? '—' : days; +} \ No newline at end of file diff --git a/services/jiraSummarizer.js b/services/jiraSummarizer.js new file mode 100644 index 0000000..dd7d23d --- /dev/null +++ b/services/jiraSummarizer.js @@ -0,0 +1,100 @@ +// src/services/jiraSummarizer.js +import { callGrok } from '../utils/grokClient.js'; +import { adfToPlainText } from '../utils/adfToPlainText.js'; +import { logger } from '../utils/logger.js'; + +const GROK_DELAY_MS = 800; + +export async function summarizeJiraTicket(ticket) { + const fields = ticket.fields || {}; + const key = ticket.key; + + const summary = fields.summary || ''; + const description = fields.description + ? adfToPlainText(fields.description) + : ''; + + const comments = fields.comment?.comments || []; + let commentText = ''; + + if (comments.length > 0) { + commentText = comments.map(c => { + const date = new Date(c.created).toLocaleDateString('en-US'); + const author = c.author?.displayName || 'Unknown'; + let body = typeof c.body === 'string' ? c.body : adfToPlainText(c.body); + return `[${date}] ${author}:\n${body}`; + }).join('\n\n'); + } else { + commentText = 'No comments found in the ticket.'; + } + + const prompt = ` +You are an expert IT support analyst summarizing Jira tickets. + +**Ticket:** ${key} +**Summary:** ${summary} +**Description:** ${description} + +**Comments/Notes (chronological):** +${commentText} + +Analyze the ticket and respond with **exactly** this structure. Do not add any extra text, headings, or explanations outside these sections: + +**Reported Problem:** +Describe the original issue clearly and concisely. + +**Steps Taken:** +List the key troubleshooting and resolution steps taken (pull heavily from comments). + +**Final Resolution:** +State how the issue was ultimately resolved or closed. If still open, note the current status and any pending actions. + +Keep each section to 2-4 sentences maximum. Be factual and technical. +`; + + try { + await new Promise(resolve => setTimeout(resolve, GROK_DELAY_MS)); + + const aiResponse = await callGrok(prompt, { + temperature: 0.35, + max_tokens: 500 + }); + + return aiResponse.trim(); + } catch (err) { + logger('jira:summarizer', `Failed for ${key}: ${err.message}`, 'error'); + return `**Reported Problem:** No details available.\n**Steps Taken:** No information recorded.\n**Final Resolution:** No resolution documented.`; + } +} + +export async function analyzeCommonIssues(tickets) { + if (tickets.length < 3) return ''; + + const ticketData = tickets.map(t => ({ + key: t.key, + summary: t.fields.summary || '', + component: t.fields.components?.[0]?.name || '', + status: t.fields.status?.name || '' + })); + + const prompt = ` +You are analyzing multiple Jira tickets for the same store. + +Tickets: +${JSON.stringify(ticketData, null, 2)} + +Identify 2–4 common patterns or recurring issues across these tickets. +Focus on technical root causes or trends (e.g., "Multiple wireless phone audio issues after firmware updates", "Frequent voicemail greeting resets", etc.). + +Respond with a short bullet-point list. Be concise and specific. +`; + + try { + await new Promise(resolve => setTimeout(resolve, 1200)); // extra delay for analysis + const analysis = await callGrok(prompt, { temperature: 0.5, max_tokens: 350 }); + return analysis; + } catch (err) { + logger('jira:summarizer', `Analysis failed: ${err.message}`, 'error'); + return "Unable to identify common patterns at this time."; + } +} \ No newline at end of file diff --git a/services/phoneDeviceBuilder.js b/services/phoneDeviceBuilder.js new file mode 100644 index 0000000..ab98898 --- /dev/null +++ b/services/phoneDeviceBuilder.js @@ -0,0 +1,50 @@ +// src/services/phoneDeviceBuilder.js +// +// Thin wrapper for phone data rich/build shape for the phone store dashboard. +// Delegates to collectPhoneStatus (which already does Webex phones/DECT + Meraki attach + relevant ports). +// Adds optional Meraki topology for viz mirroring the AV dashboard. +// Keeps backward compat for the /phonestatus command (which uses collect directly). + +import { collectPhoneStatus } from './phoneService.js'; +import { getMerakiTopology } from '../integrations/meraki/devices.js'; +import { logger } from '../utils/logger.js'; + +export async function buildPhoneDevices(storeNumber) { + logger('phone:builder', `Building phone devices for store ${storeNumber} (rich shape for dashboard)`, 'debug'); + + try { + const data = await collectPhoneStatus(storeNumber); + + // Fetch topology (linkLayer) for rich dashboard viz, similar to AV. + // Use networkId from the meraki enrichment if present. + let merakiTopology = null; + const networkId = data.meraki?.networkId || (data.meraki?.network && data.meraki.network.id); + if (networkId) { + try { + merakiTopology = await getMerakiTopology(networkId); + logger('phone:builder', `Fetched Meraki topology for network ${networkId}: ${merakiTopology?.nodes?.length || 0} nodes`, 'debug'); + } catch (topoErr) { + logger('phone:builder', `Failed to fetch Meraki topology: ${topoErr.message}`, 'warn'); + merakiTopology = { nodes: [], links: [], errors: [topoErr.message] }; + } + } + + const result = { + success: true, + storeNumber: String(storeNumber), + ...data, + merakiTopology, + deviceCount: (data.phones?.data?.length || 0) + (data.dectBasestations?.length || 0), + lastUpdated: new Date().toISOString() + }; + + logger('phone:builder', `Phone build complete for store ${storeNumber} (${result.deviceCount} devices)`); + return result; + + } catch (err) { + logger('phone:builder', `Phone build failed for store ${storeNumber}: ${err.message}`, 'error'); + throw err; + } +} + +export default buildPhoneDevices; \ No newline at end of file diff --git a/services/phoneService.js b/services/phoneService.js new file mode 100644 index 0000000..be7cf63 --- /dev/null +++ b/services/phoneService.js @@ -0,0 +1,1103 @@ +// src/services/phoneService.js +import webex from '../integrations/webex/WebexClient.js'; + +import { + getClientsForStore, + getPortsForStore +} from '../integrations/meraki/clients.js'; + +import { logger } from '../utils/logger.js'; +import { normalizeMac } from './enrichment/normalizers.js'; +import { attachMerakiClientWithPorts } from './enrichment/merakiEnrichment.js'; + +// ────────────────────────────────────────────── +// Main public function +// ────────────────────────────────────────────── +export async function collectPhoneStatus(storeNumber) { + logger('phone:service', `Starting phone status collection for store ${storeNumber}`, 'debug'); + + const storeNum = String(storeNumber); + const email = `ae${storeNum.padStart(5, '0')}@ae.com`; + logger('phone:service', `Looking up person for store ${storeNum} → email: ${email}`, 'debug'); + + const personId = await getPersonIdByEmail(email); + + // Fetch Webex (phones + dect networks) + clients first, so we can determine relevant switches for ports + // (optimization to reduce Meraki API load / 429 risk, consistent with AV path) + const [phonesRes, dectNetworksRes, clientsRes, personDetailsRes, telephonyProfileRes] = await Promise.allSettled([ + getWebexPhonesForStore(storeNum), + personId ? getDectNetworksForPerson(personId) : Promise.resolve([]), + getClientsForStore(storeNum, 1), // 1 day for recent activity/usage in phone status (AV paths keep 7d default) + personId ? getPersonDetails(personId) : Promise.resolve(null), + personId ? getTelephonyProfile(personId) : Promise.resolve({}), + ]); + + // Tier 2: location main number (auto attendant / full store DID, e.g. +12122194600). + // Kicked off here (right after batch, using dectNetworks result) so it runs in parallel + // with the DECT bases/handsets detail fetches inside the block below. 1 cheap call. + // Tier 2 location main number (auto attendant / full store DID) + let locationDetailsPromise = Promise.resolve(null); + const dectNetsRaw = dectNetworksRes.status === 'fulfilled' ? (dectNetworksRes.value || []) : []; + if (dectNetsRaw.length > 0) { + const locId = dectNetsRaw[0].locationId || dectNetsRaw[0].location?.id; + if (locId && locId !== '—') { + locationDetailsPromise = webex.request('GET', `telephony/config/locations/${locId}`).catch(() => null); + } + } + + let phonesList = []; + if (phonesRes.status === 'fulfilled' && Array.isArray(phonesRes.value)) { + phonesList = phonesRes.value; + logger('phone:service', `Received ${phonesList.length} desk phones from Webex`, 'debug'); + } else { + logger('phone:service', `Webex phones fetch failed`, 'warn'); + } + + let clientsData = clientsRes.status === 'fulfilled' ? (clientsRes.value || {}) : {}; + const clients = Array.isArray(clientsData) ? clientsData : (clientsData.clients || []); + const networkUrl = clientsData.network?.url || ''; + + const personDetails = personDetailsRes.status === 'fulfilled' ? personDetailsRes.value : null; + const telephonyProfile = telephonyProfileRes.status === 'fulfilled' ? telephonyProfileRes.value : {}; + + // DECT (needs person + networks) + let dectBasestations = []; + let dectHandsets = []; + let dectNetwork = null; + if (dectNetworksRes.status === 'fulfilled' && dectNetworksRes.value?.length > 0) { + dectNetwork = dectNetworksRes.value[0]; + const networkId = dectNetwork.id; + const locationId = dectNetwork.locationId; + + if (locationId && networkId) { + logger('phone:service', `Fetching DECT data for network ${networkId}`, 'debug'); + + const [basesResult, handsetsResult] = await Promise.allSettled([ + getDectBasestations(locationId, networkId), + getDectHandsets(locationId, networkId) + ]); + + if (basesResult.status === 'fulfilled') dectBasestations = basesResult.value || []; + + if (handsetsResult.status === 'fulfilled' && handsetsResult.value?.length > 0) { + const fullHandsets = await Promise.all( + handsetsResult.value.map(async basic => { + const detail = await getDectHandsetDetails(locationId, networkId, basic.id) || {}; + return { ...basic, ...detail }; // preserve mac from basic list, baseStationId etc from detail + }) + ); + dectHandsets = fullHandsets.filter(h => h); + } + } + } + + // Tier 2: await the parallel location fetch (started above) to get main number. + // This is the full E.164 assigned to the auto attendant for the store/location. + const locationDetails = await locationDetailsPromise; + const locationMainNumber = locationDetails?.callingLineId?.phoneNumber || locationDetails?.phoneNumber || null; + + // Collect MACs of interest (phones + basestations) to compute relevant switches for ports + const interestMacs = new Set(); + phonesList.forEach(p => { + const k = normalizeMac(p.mac); + if (k) interestMacs.add(k); + }); + dectBasestations.forEach(b => { + const k = normalizeMac(b.mac); + if (k) interestMacs.add(k); + }); + + const relevantSwitchesForPorts = new Set(); + for (const c of clients) { + const cMac = normalizeMac(c.mac); + if (interestMacs.has(cMac) && c.recentDeviceSerial) { + const conn = (c.recentDeviceConnection || '').toLowerCase(); + if (!conn.includes('wireless')) { + relevantSwitchesForPorts.add(c.recentDeviceSerial); + } + } + } + + // Fetch ports using relevant switches (saves API calls, consistent with AV optimization) + const portsRes = await getPortsForStore(storeNum, relevantSwitchesForPorts); + const portConfigs = Array.isArray(portsRes) ? portsRes : (portsRes?.ports || portsRes || []); + + const portStatusCache = new Map(); + + // Enrich desk phones using shared Meraki attach (now supports direct .mac, reuses client+port logic + cache) + const phoneBaseDevices = phonesList.map(ph => ({ + mac: ph.mac, + identifier: ph.name || ph.displayName || ph.mac || '' + })); + for (const dev of phoneBaseDevices) { + await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl); + } + + const enrichedPhones = phonesList.map((ph, i) => { + const attached = phoneBaseDevices[i].meraki || {}; + const client = attached.client || attached; + // Flatten for backward compat with phoneStatus command (expects .port, .switchName etc at top of meraki) + return { + ...ph, + meraki: { + port: client.portNumber || client.switchport || client.port, + switchName: client.deviceName || client.recentDeviceName || client.switchName, + status: client.status || client.switchportStatus?.status || client.status, + vlan: client.vlan, + ip: client.ip, + lastSeen: client.lastSeen, + portName: client.portName, + poeEnabled: client.poeEnabled, + connectionType: attached.connectionType, + clientUrl: attached.clientUrl || '', + ...client + } + }; + }); + + logger('phone:service', `Final enriched desk phones: ${enrichedPhones.length}`, 'debug'); + + // Enrich DECT basestations the same way (they also show Meraki port info in command) + const baseBaseDevices = dectBasestations.map(b => ({ + mac: b.mac, + identifier: b.name || b.mac || '' + })); + for (const dev of baseBaseDevices) { + await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl); + } + + const enrichedBasestations = dectBasestations.map((b, i) => { + const attached = baseBaseDevices[i].meraki || {}; + const client = attached.client || attached; + return { + ...b, + meraki: { + port: client.portNumber || client.switchport || client.port, + switchName: client.deviceName || client.recentDeviceName || client.switchName, + status: client.status || client.switchportStatus?.status || client.status, + vlan: client.vlan, + ip: client.ip, + lastSeen: client.lastSeen, + portName: client.portName, + poeEnabled: client.poeEnabled, + connectionType: attached.connectionType, + clientUrl: attached.clientUrl || '', + ...client + } + }; + }); + + // Enrich DECT handsets with Meraki client data too (match on MAC, per user request for topology) + const handsetBaseDevices = dectHandsets.map(h => ({ + mac: h.mac, + identifier: h.name || h.mac || '' + })); + for (const dev of handsetBaseDevices) { + await attachMerakiClientWithPorts(dev, clients, portConfigs, portStatusCache, networkUrl); + } + + const enrichedHandsets = dectHandsets.map((h, i) => { + const attached = handsetBaseDevices[i].meraki || {}; + const client = attached.client || attached; + return { + ...h, + meraki: { + port: client.portNumber || client.switchport || client.port, + switchName: client.deviceName || client.recentDeviceName || client.switchName, + status: client.status || client.switchportStatus?.status || client.status, + vlan: client.vlan, + ip: client.ip, + lastSeen: client.lastSeen, + portName: client.portName, + poeEnabled: client.poeEnabled, + connectionType: attached.connectionType, + clientUrl: attached.clientUrl || '', + ...client + } + }; + }); + + return { + phones: { + status: phonesRes.status === 'fulfilled' ? 'success' : 'failed', + data: enrichedPhones + }, + dectBasestations: enrichedBasestations, + dectHandsets: enrichedHandsets, + dectNetwork, + person: personDetails, + telephonyProfile, + locationMainNumber, // Tier 2: full store main number (E.164) from location callingLineId; assigned to auto attendant for this store + meraki: { + status: clientsRes.status === 'fulfilled' ? 'success' : 'failed', + data: clients || [], + network: clientsData.network || null, + networkId: clientsData.networkId || (clientsData.network && clientsData.network.id) || null + } + }; +} + +// ────────────────────────────────────────────── +// DECT & Webex helper functions +// ────────────────────────────────────────────── + +export async function getPersonIdByEmail(email) { + if (!email) { + logger('phone:service', 'No email provided for lookup', 'warn'); + return null; + } + + try { + logger('phone:service', `Looking up user by email: ${email}`, 'debug'); + const response = await webex.request('GET', 'people', null, { email }); + const people = response.items || []; + + if (people.length === 0) { + logger('phone:service', `No user found for email: ${email}`, 'warn'); + return null; + } + + const person = people[0]; + logger('phone:service', `Found person ID: ${person.id}`, 'debug'); + return person.id; + } catch (err) { + logger('phone:service', `Error looking up user: ${err.message}`, 'error'); + return null; + } +} + +export async function getDectNetworksForPerson(personId) { + if (!personId) return []; + + try { + logger('phone:service', `Fetching DECT networks for person ${personId}`); + const response = await webex.request('GET', `telephony/config/people/${personId}/dectNetworks`); + const networks = response.dectNetworks || []; + logger('phone:service', `DECT networks found: ${networks.length}`, 'debug'); + return networks.map(net => ({ + id: net.id, + name: net.name || 'Unknown', + handsetsCount: net.numberOfHandsetsAssigned || 0, + locationName: net.location?.name || '—', + locationId: net.location?.id || '—' + })); + } catch (err) { + logger('phone:service', `Error fetching DECT networks: ${err.message}`, 'error'); + return []; + } +} + +export async function getDectBasestations(locationId, dectNetworkId) { + if (!locationId || !dectNetworkId) return []; + + try { + const response = await webex.request( + 'GET', + `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations` + ); + const baseStations = response?.items ?? response?.baseStations ?? []; + return baseStations.map(base => ({ + id: base.id, + mac: base.mac || base.macAddress || base.baseMac || '—', + name: base.displayName || `Basestation ${base.mac || base.macAddress || 'Unknown'}`, + status: base.status || 'unknown', + lastSeen: base.lastSeen || 'unknown', + firmware: base.softwareVersion || '—', + model: base.model || '—', + ipAddress: base.ip || '—', + linesRegistered: base.numberOfLinesRegistered || 0 + })); + } catch (err) { + logger('phone:service', `Error fetching basestations: ${err.message}`, 'error'); + return []; + } +} + +export async function getDectHandsets(locationId, dectNetworkId) { + if (!locationId || !dectNetworkId) return []; + + try { + const response = await webex.request( + 'GET', + `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets` + ); + const handsets = response?.items ?? response?.handsets ?? []; + return handsets.map(handset => ({ + id: handset.id, + name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index || ''}`, + status: handset.status || 'unknown', + lastSeen: handset.lastSeen || 'unknown', + mac: handset.mac || '—', + firmware: handset.softwareVersion || '—', + model: handset.model || '—', + extension: handset.accessCode || handset.lines?.[0]?.esn || '—', + lines: handset.lines || [] + })); + } catch (err) { + logger('phone:service', `Error fetching handsets: ${err.message}`, 'error'); + return []; + } +} + +export async function getDectHandsetDetails(locationId, dectNetworkId, handsetId) { + if (!locationId || !dectNetworkId || !handsetId) return null; + + try { + const handset = await webex.request( + 'GET', + `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}` + ); + + return { + id: handset.id, + index: handset.index, + name: handset.defaultDisplayName || handset.displayName || `Handset ${handset.index || ''}`, + lastRegistrationTime: handset.lines?.[0]?.lastRegistrationTime || null, + extension: handset.lines?.[0]?.extension || null, + baseStationId: handset.baseStationId || null + }; + } catch (err) { + logger('phone:service', `Error fetching handset details: ${err.message}`, 'error'); + return null; + } +} + +// Main entry point +export async function getWebexPhonesForStore(storeNumber) { + const email = `ae${String(storeNumber).padStart(5, '0')}@ae.com`; + logger('phone:service', `getWebexPhonesForStore called for ${storeNumber}`, 'debug'); + + const personId = await getPersonIdByEmail(email); + if (!personId) { + logger('phone:service', `No person found for store ${storeNumber}`, 'warn'); + return []; + } + + const phones = await getDevicesForPerson(personId); + logger('phone:service', `Returned ${phones.length} phones for store ${storeNumber}`, 'debug'); + return phones.map(phone => ({ + // Core (kept for backward compat + display) + mac: phone.mac || '—', + name: phone.displayName || phone.product + ' ' + (phone.mac?.slice(-4) || 'Unknown') || 'Unknown Phone', + status: phone.connectionStatus || phone.status || 'unknown', + lastSeen: phone.lastSeen || 'unknown', + firmware: phone.software || phone.softwareVersion || '—', + model: phone.product || phone.model || '—', + ipAddress: phone.ip || phone.ipAddress || '—', + // Rich additions for Tier 1 (from raw /devices, carried through ...ph in enrichment) + serial: phone.serial || '—', + product: phone.product || '—', + displayName: phone.displayName || null, + sipUrls: phone.sipUrls || [], + primarySipUrl: phone.primarySipUrl || '—', + errorCodes: phone.errorCodes || [], + capabilities: phone.capabilities || [], + activeInterface: phone.activeInterface || '—', + locationId: phone.locationId || phone.workspaceLocationId || '—', + created: phone.created || phone.firstSeen || null, + upgradeChannel: phone.upgradeChannel || '—', + managedBy: phone.managedBy || '—', + lifecycle: phone.lifecycle || '—' + })); +} + +export async function getDevicesForPerson(personId) { + if (!personId) return []; + + try { + logger('phone:service', `Fetching devices for person ${personId}`, 'debug'); + let allDevices = []; + let next = null; + + do { + const params = { personId, max: 100 }; + if (next) params.next = next; + + const response = await webex.request('GET', 'devices', null, params); + const pageItems = response.items || []; + allDevices = allDevices.concat(pageItems); + next = response.next; + } while (next); + + logger('phone:service', `Total devices for person: ${allDevices.length}`, 'debug'); + return allDevices; + } catch (err) { + logger('phone:service', `Error fetching devices: ${err.message}`, 'error'); + return []; + } +} + +// ────────────────────────────────────────────── +// Tier 2: cheap extra profile fetches (person details + telephony config that actually works) +// + location main number (auto attendant / store DID) using already-known locationId from DECT. +// Discovery showed: +// - telephony/config/people/{id} → {announcementLanguage, timeZone} +// - telephony/config/people/{id}/outgoingPermission → {useCustomEnabled, callingPermissions[]} +// - /people/{id} → full person (phoneNumbers, displayName etc.) +// - telephony/config/locations/{id} → callingLineId.phoneNumber (the main +1 number assigned to AA) +// DND/callForwarding etc. still 404 under current scopes → resilient, only log at debug/warn. +// Location fetch started early for parallelism with DECT detail calls. +// ────────────────────────────────────────────── + +export async function getPersonDetails(personId) { + if (!personId) return null; + try { + logger('phone:service', `Fetching full person details for ${personId}`, 'debug'); + return await webex.request('GET', `people/${personId}`); + } catch (err) { + logger('phone:service', `Error fetching person details: ${err.message}`, 'warn'); + return null; + } +} + +export async function getTelephonyProfile(personId) { + if (!personId) return {}; + try { + logger('phone:service', `Fetching telephony profile for ${personId}`, 'debug'); + const [profileRes, permRes] = await Promise.allSettled([ + webex.request('GET', `telephony/config/people/${personId}`), + webex.request('GET', `telephony/config/people/${personId}/outgoingPermission`), + ]); + const base = profileRes.status === 'fulfilled' ? profileRes.value : {}; + const outgoing = permRes.status === 'fulfilled' ? permRes.value : null; + return { + ...base, + outgoingPermission: outgoing, + }; + } catch (err) { + logger('phone:service', `Error fetching telephony profile: ${err.message}`, 'warn'); + return {}; + } +} + +// ────────────────────────────────────────────── +// Tier 3 start: recent activity / "call proxy" using data we already have (lastSeen + 1d Meraki usage + DECT reg). +// Real historical (in/out/missed counts + samples) attempted via Detailed Call History CDR Feed (/cdr_feed on analytics* base). +// Falls back gracefully with reason + exact scopes/role hint until Control Hub role + scope propagate and return 2xx items. +// ────────────────────────────────────────────── + +export function computeRecentActivity(phones = [], dectBases = [], dectHandsets = [], merakiClients = []) { + const now = Date.now(); + const windowMs = 12 * 60 * 60 * 1000; // 12h to align with historical CDR max window + + const phoneActivity = phones.map(ph => { + const ls = ph.lastSeen ? Date.parse(ph.lastSeen) : 0; + const mls = ph.meraki?.lastSeen ? Date.parse(ph.meraki.lastSeen) : 0; + const last = Math.max(ls, mls) || 0; + const within = last > 0 && (now - last) < windowMs; + const usage = ph.meraki?.usage || { sent: 0, recv: 0, total: 0 }; + return { + identifier: ph.name || ph.mac, + mac: ph.mac, + recent12h: within, + lastSeen: ph.lastSeen || ph.meraki?.lastSeen || null, + usageBytes: usage.total || (usage.sent || 0) + (usage.recv || 0), + status: ph.status, + }; + }); + + const baseActivity = dectBases.map(b => { + const ls = b.lastSeen && b.lastSeen !== 'unknown' ? Date.parse(b.lastSeen) : 0; + const mls = b.meraki?.lastSeen ? Date.parse(b.meraki.lastSeen) : 0; + const last = Math.max(ls, mls) || 0; + const within = last > 0 && (now - last) < windowMs; + const usage = b.meraki?.usage || { sent: 0, recv: 0, total: 0 }; + return { + mac: b.mac, + recent12h: within, + lastSeen: b.lastSeen !== 'unknown' ? b.lastSeen : (b.meraki?.lastSeen || null), + linesRegistered: b.linesRegistered, + usageBytes: usage.total || (usage.sent || 0) + (usage.recv || 0), + hasSwitch: !!(b.meraki && (b.meraki.switchName || b.meraki.port)), + }; + }); + + const handsetActivity = dectHandsets.map(h => { + const reg = h.lastRegistrationTime ? (typeof h.lastRegistrationTime === 'number' ? h.lastRegistrationTime : Date.parse(h.lastRegistrationTime)) : 0; + const within = reg > 0 && (now - reg) < windowMs; + return { + name: h.name, + extension: h.extension, + recent12h: within, + lastRegistration: h.lastRegistrationTime || null, + baseStationId: h.baseStationId, + }; + }); + + const activePhones = phoneActivity.filter(a => a.recent12h).length; + const activeBases = baseActivity.filter(a => a.recent12h).length; + const activeHandsets = handsetActivity.filter(a => a.recent12h).length; + const totalUsage = [...phoneActivity, ...baseActivity].reduce((s, a) => s + (a.usageBytes || 0), 0); + + return { + windowHours: 12, + summary: { + activePhones12h: activePhones, + activeDECTBases12h: activeBases, + activeHandsets12h: activeHandsets, + totalDataUsageBytes12h: totalUsage, + }, + phones: phoneActivity, + dectBasestations: baseActivity, + handsets: handsetActivity, + }; +} + +// Tier 3: attempt to fetch historical call data via the Detailed Call History (CDR Feed) API. +// Canonical endpoint per the doc at: +// https://developer.webex.com/calling/docs/api/v1/reports-detailed-call-history/get-detailed-call-history +// - Path: /cdr_feed on https://analytics-calling.webexapis.com/v1 (preferred; the analytics.webexapis.com variant may also work depending on routing) +// - Required params: startTime + endTime (full ISO with ms+Z; **max 12h range per request**), locations (the *name* of the location, e.g. "Store 0782" or "Store 2477"; required) +// - Optional: max (500-5000 per page) +// - Hard constraints (per doc + testing): +// - endTime must be at least ~5 minutes in the past (data availability delay). +// - Max 12h of records per call. +// - Rate limit: 1 call per minute + up to 10 pagination calls per minute per token. +// - Auth: same Service App bearer (webex-service-tokens.json) + spark-admin:calling_cdr_read scope + Control Hub role "Webex Calling Detailed Call History API access". +// Response shape: { items: [...] } with call records. +// We always fetch a **single** compliant recent window (the freshest ~12h allowed by the 5min delay + 12h max) to strictly obey the 1 call/min rate limit. +// No multi-window back-to-back calls in one collectPhoneStatus (would 429). +// The `locations` name comes from the person's DECT network (already fetched for main number / dect logic). +// Secondary client-side filter on person numbers inside the location results. +// (recent activity and historical calls features disabled per request) +export async function getHistoricalCallActivity(personId, hours = 24, options = {}) { + if (!personId) return { available: false, reason: 'no personId', calls: [], summary: {} }; + + const { locationName: providedLocationName } = options || {}; + + // Prefer analytics-calling (user-confirmed working base for cdr_feed). Fall back to the other if needed. + const ANALYTICS_BASES = [ + 'https://analytics-calling.webexapis.com/v1', + 'https://analytics.webexapis.com/v1' + ]; + const CDR_PATH = '/cdr_feed'; + const MAX_PER_PAGE = 1000; + + try { + const person = await webex.request('GET', `people/${personId}`); + const orgId = person?.orgId; + + const token = await webex.auth.getAccessToken(); + const axiosMod = (await import('axios')).default; + + // Get the *required* locations=name from DECT (passed in or self-discover). + let locationName = providedLocationName; + if (!locationName || locationName === '—') { + try { + const dectNets = await getDectNetworksForPerson(personId); + if (dectNets.length > 0) { + locationName = dectNets[0].locationName || dectNets[0].location?.name || null; + } + } catch (e) { + logger('phone:service', `Could not self-fetch DECT networks for CDR location context: ${e.message}`, 'debug'); + } + } + + if (!locationName || locationName === '—') { + return { + available: false, + reason: 'no locationName (the /cdr_feed API requires the "locations" param using the location *name* from the store/person DECT config)', + scopesNeeded: 'Control Hub role + DECT/network location name must be resolvable for this person', + calls: [], + summary: {}, + }; + } + + // Person identifiers for secondary filtering inside the location results. + const userIds = new Set(); + if (person?.displayName) userIds.add(String(person.displayName).toLowerCase()); + (person?.emails || []).forEach(e => { if (e) userIds.add(String(e).toLowerCase()); }); + (person?.phoneNumbers || []).forEach(p => { + const v = p?.value || p; + if (!v) return; + userIds.add(String(v).toLowerCase()); + const digits = String(v).replace(/\D/g, ''); + if (digits) { + userIds.add(digits); + if (digits.length >= 4) userIds.add(digits.slice(-4)); + if (digits.length >= 5) userIds.add(digits.slice(-5)); + } + }); + + const matchItemToUser = (c) => { + if (!c) return false; + const candidates = [ + c.callingNumber, c.calledNumber, c.redirectingNumber, + c.callingParty, c.calledParty, c.remoteParty, c.partyNumber, + c.userName, c.user, c.originator, c.terminator, + c.callingName, c.calledName + ].filter(Boolean).map(x => String(x)); + for (const val of candidates) { + const low = val.toLowerCase(); + const dig = val.replace(/\D/g, ''); + for (const id of userIds) { + const idStr = String(id); + if (low.includes(idStr) || (dig && dig.includes(idStr))) return true; + } + } + const uid = c.userId || c.personId || c.ownerId || (c.user && c.user.id); + if (uid && String(uid) === String(personId)) return true; + return false; + }; + + // Compute a *single* compliant window for the freshest possible data: + // - endTime = now - 5 minutes (API requires data at least ~5min old) + // - window = 12h max (API hard limit) + // This respects rate limits (exactly 1 cdr_feed call per collectPhoneStatus). + // We do not do multiple windows back-to-back (would violate 1 call/min). + const FIVE_MIN_MS = 5 * 60 * 1000; + const TWELVE_HOURS_MS = 12 * 3600 * 1000; + + const endMs = Date.now() - FIVE_MIN_MS; + const startMs = endMs - TWELVE_HOURS_MS; + + const startTime = new Date(startMs).toISOString(); + const endTime = new Date(endMs).toISOString(); + + logger('phone:service', `Attempting Detailed Call History via cdr_feed for person ${personId} (org ${orgId || 'unknown'}) location="${locationName}" window=${startTime}..${endTime}`, 'debug'); + + const allRawItems = []; + const fetchErrors = []; + + // Helper to robustly extract list, supporting: + // - direct array + // - .items as array + // - .items as { "0": rec, "1": rec, ... } (items[0], items[1] style) + // - top level numeric keys on the data object + // - fallback to first array value found + function extractList(data) { + if (!data) return []; + if (Array.isArray(data)) return data; + if (Array.isArray(data.items)) return data.items; + if (data.items && typeof data.items === 'object' && data.items !== null) { + const keys = Object.keys(data.items).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b)); + if (keys.length > 0) return keys.map(k => data.items[k]); + } + // top-level numeric keyed? + const topKeys = Object.keys(data).filter(k => /^\d+$/.test(k)).sort((a, b) => parseInt(a) - parseInt(b)); + if (topKeys.length > 0) return topKeys.map(k => data[k]); + // any array value + for (const v of Object.values(data)) { + if (Array.isArray(v)) return v; + } + return []; + } + + let usedBase = null; + let firstRespData = null; + + for (const base of ANALYTICS_BASES) { + const url = `${base}${CDR_PATH}`; + const params = { startTime, endTime, locations: locationName, max: MAX_PER_PAGE }; + const queryStr = new URLSearchParams(params).toString(); + const fullUrl = `${url}?${queryStr}`; + logger('phone:service', `cdr_feed request URL: ${fullUrl}`, 'debug'); + + try { + const resp = await axiosMod.get(url, { + headers: { Authorization: `Bearer ${token}` }, + params, + timeout: 15000 + }); + logger('phone:service', `cdr_feed response status=${resp.status} base=${base}`, 'debug'); + + const d = resp.data || {}; + firstRespData = d; + + const list = extractList(d); + allRawItems.push(...list); + usedBase = base; + logger('phone:service', `cdr_feed hit on ${base} (loc=${locationName}) → ${list.length} raw (keys=${Object.keys(d).slice(0,8).join(',')})`, 'debug'); + + // Pagination support (up to 10 additional pages per rate limit) + let nextToken = d.next || d['next'] || (d.metadata && d.metadata.next) || null; + let page = 1; + const MAX_PAGES = 10; + while (nextToken && page < MAX_PAGES) { + page++; + let pageUrl = url; + let pageParams = { ...params, next: nextToken }; + if (typeof nextToken === 'string' && nextToken.startsWith('http')) { + pageUrl = nextToken; + pageParams = null; + } + const pageQuery = pageParams ? new URLSearchParams(pageParams).toString() : ''; + const pageFull = pageParams ? `${pageUrl}?${pageQuery}` : pageUrl; + logger('phone:service', `cdr_feed pagination page ${page} URL: ${pageFull}`, 'debug'); + try { + const pResp = await axiosMod.get(pageUrl, { + headers: { Authorization: `Bearer ${token}` }, + params: pageParams, + timeout: 15000 + }); + logger('phone:service', `cdr_feed pagination status=${pResp.status}`, 'debug'); + const pd = pResp.data || {}; + const pList = extractList(pd); + allRawItems.push(...pList); + nextToken = pd.next || pd['next'] || (pd.metadata && pd.metadata.next) || null; + } catch (pe) { + const pst = pe.response?.status; + logger('phone:service', `cdr_feed pagination ERROR status=${pst || 'n/a'}: ${pe.message}`, 'warn'); + break; + } + } + break; // first successful HTTP response (even if 0 items = empty window is valid) + } catch (e) { + const st = e.response?.status; + const bd = e.response?.data || e.message; + logger('phone:service', `cdr_feed ERROR for ${fullUrl} status=${st || 'n/a'}: ${JSON.stringify(bd).slice(0,300)}`, 'debug'); + fetchErrors.push({ base, startTime, endTime, status: st, body: bd }); + } + } + + if (allRawItems.length === 0) { + // Legacy fallback (rarely useful now) + try { + const legUrl = `${ANALYTICS_BASES[0]}/callHistory`; + const r = await axiosMod.get(legUrl, { + headers: { Authorization: `Bearer ${token}` }, + params: { orgId, personId, startTime, endTime, max: 100 }, + timeout: 15000 + }); + const d = r.data || {}; + const li = extractList(d); + if (li.length >= 0) { + allRawItems.push(...li); + logger('phone:service', `legacy callHistory fallback gave ${li.length}`, 'debug'); + } + } catch (_) { /* ignore */ } + } + + const receivedRaw = allRawItems.length; + + // Dedup across pages/windows (keep for safety). Use richer key to avoid over-collapsing. + const seen = new Set(); + const items = []; + for (const c of allRawItems) { + const key = [ + c.startTime || c.start || c.callStartTime || '', + c.callingNumber || '', + c.calledNumber || '', + c.duration || c.callDuration || '', + c.callId || c.id || c.uuid || '' + ].join('|'); + if (!seen.has(key)) { + seen.add(key); + items.push(c); + } + } + + // Secondary person filter inside the location-scoped results. + const userItems = items.filter(matchItemToUser); + const effectiveItems = (userItems.length > 0) ? userItems : items; + if (userItems.length === 0 && items.length > 0) { + logger('phone:service', `cdr_feed: ${items.length} unique (received ${receivedRaw}, loc=${locationName}) but 0 matched person ids; using location results (common for main/AA numbers)`, 'debug'); + } + + // Flexible parse (CDR field names vary; covers common report columns) + let inbound = 0, outbound = 0, missed = 0, totalDuration = 0; + const samples = []; + for (const c of effectiveItems) { + const dir = String(c.direction || c.callDirection || c.callType || c.directionIndicator || '').toUpperCase(); + const dur = Number(c.durationSeconds || c.duration || c.callDuration || c.talkDuration || c.length || 0); + const status = String(c.status || c.result || c.callResult || c.callOutcome || c.callStatus || '').toLowerCase(); + + if (dir.includes('IN') || dir === 'INBOUND' || dir.includes('INCOMING')) inbound++; + else if (dir.includes('OUT') || dir === 'OUTBOUND' || dir.includes('OUTGOING')) outbound++; + + if (status.includes('miss') || dir.includes('MISS') || status.includes('no answer') || status.includes('failed') || status.includes('busy')) missed++; + + totalDuration += dur; + + if (samples.length < 5) { + samples.push({ + start: c.startTime || c.start || c.callStartTime || c.answerTime || c.releaseTime, + direction: dir || c.direction, + duration: dur, + status: c.status || c.result || c.callResult, + otherParty: c.otherParty || c.calledParty || c.callingParty || c.remoteParty || c.calledNumber || c.callingNumber || 'unknown', + phoneNumber: c.phoneNumber || c.calledNumber || c.callingNumber, + }); + } + } + + return { + available: true, + source: 'analytics-calling.webexapis.com/v1/cdr_feed', + hours: 12, // actual window size (API max 12h, ending >=5min ago) + location: locationName, + startTime, + endTime, + summary: { + inbound, + outbound, + missed, + totalCalls: effectiveItems.length, + totalDurationSeconds: totalDuration, + }, + samples, + rawCount: receivedRaw, + userMatchedCount: userItems.length, + }; + } catch (err) { + const status = err.response?.status; + const body = err.response?.data || {}; + const msg = body.message || body.error || body.description || err.message; + logger('phone:service', `Historical call history (detailed/cdr_feed) unavailable (${status || 'err'}): ${msg} — falling back to proxies.`, 'warn'); + + let reason = status ? `endpoint returned ${status}: ${msg}` : msg; + if (status === 429) { + reason = 'rate limited (1 cdr_feed call per minute + 10 pagination per min per token). Wait ~60s and retry.'; + } + return { + available: false, + reason, + scopesNeeded: status === 429 ? 'Respect API rate limits (see doc)' : 'spark-admin:calling_cdr_read scope + Control Hub administrator role "Webex Calling Detailed Call History API access" enabled for the authorizing user', + errorDetails: body, + calls: [], + summary: {}, + }; + } +} + +// ────────────────────────────────────────────── +// DECT Provisioning helpers for /provision-dect +// Lookup uses 5-digit padded email (aeXXXXX@ae.com) +// Network name is "Store XXXX" (4-digit padded) +// Access codes: 4 digits; 4-digit store uses store#; shorter stores prefix 8 +// ────────────────────────────────────────────── + +/** + * Find DECT network for store by looking up person (5-digit) then matching network name "Store XXXX" (4-digit pad). + */ +export async function findDectNetworkForStore(storeNumber) { + const storeStr = String(storeNumber).trim(); + const padded5 = storeStr.padStart(5, '0'); + const email = `ae${padded5}@ae.com`; + logger('phone:provision', `Looking up DECT network for store ${storeStr} (5-digit email ${email})`, 'debug'); + + const personId = await getPersonIdByEmail(email); + if (!personId) { + logger('phone:provision', `No person found for email ${email}`, 'warn'); + return null; + } + + const networks = await getDectNetworksForPerson(personId); + const padded4 = storeStr.padStart(4, '0'); + const targetName = `Store ${padded4}`; + + const match = networks.find(n => { + const nm = (n.name || '').trim(); + return nm.toLowerCase() === targetName.toLowerCase() || + nm.toLowerCase().includes(padded4); + }); + + if (match) { + logger('phone:provision', `Found DECT network "${match.name}" (id=${match.id}, loc=${match.locationId || match.location?.id})`, 'debug'); + } else { + logger('phone:provision', `No matching DECT network "${targetName}" found among ${networks.length} networks`, 'warn'); + } + return match || null; +} + +/** + * Generate 4-digit access code per rules: + * - If store is 4+ digits: use first 4 digits of store number + * - If fewer digits: '8' + pad the digits to 3 positions (e.g. 347→8347, 67→8067) + */ +export function generateDectAccessCode(storeNumber) { + const digits = String(storeNumber).replace(/\D/g, ''); + if (digits.length >= 4) { + return digits.substring(0, 4); + } + const rest = digits.padStart(3, '0'); + return '8' + rest; +} + +/** + * Get current DECT status for provisioning UI (network + bases + handsets with details). + */ +export async function getDectProvisioningStatus(storeNumber) { + const network = await findDectNetworkForStore(storeNumber); + if (!network) { + return { network: null, basestations: [], handsets: [] }; + } + + const locationId = network.locationId || network.location?.id; + const networkId = network.id; + if (!locationId || !networkId) { + return { network, basestations: [], handsets: [] }; + } + + const [basesRes, handsRes] = await Promise.allSettled([ + getDectBasestations(locationId, networkId), + getDectHandsets(locationId, networkId) + ]); + + let basestations = basesRes.status === 'fulfilled' ? (basesRes.value || []) : []; + let handsets = handsRes.status === 'fulfilled' ? (handsRes.value || []) : []; + + // Build map id -> mac for bases + const baseMacMap = {}; + basestations.forEach(b => { + if (b.id) baseMacMap[b.id] = b.mac || '—'; + }); + + // Enrich handsets with details (for baseStationId etc.) and base MAC + if (handsets.length > 0 && locationId && networkId) { + handsets = await Promise.all(handsets.map(async (h) => { + const detail = await getDectHandsetDetails(locationId, networkId, h.id); + const enriched = { ...h, ...(detail || {}) }; + enriched.baseMac = baseMacMap[enriched.baseStationId] || '—'; + return enriched; + })); + } + + return { network, basestations, handsets }; +} + +/** + * Add one or more basestations (MACs comma or space separated). + * Idempotent: skips if MAC already present. + */ +export async function addDectBasestation(locationId, dectNetworkId, macInput) { + if (!locationId || !dectNetworkId || !macInput) { + throw new Error('locationId, dectNetworkId and mac(s) required'); + } + + const macs = String(macInput) + .split(/[\s,]+/) + .map(m => m.trim()) + .filter(Boolean); + + const results = []; + for (const rawMac of macs) { + const clean = rawMac.replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (clean.length !== 12) { + results.push({ mac: rawMac, error: 'invalid MAC (need 12 hex chars)' }); + continue; + } + const formatted = clean.match(/.{1,2}/g).join(':'); + + try { + // Idempotency check + const existing = await getDectBasestations(locationId, dectNetworkId); + const already = existing.some(b => { + const bmac = String(b.mac || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + return bmac === clean; + }); + if (already) { + logger('phone:provision', `Basestation ${formatted} already present - skipping`, 'debug'); + results.push({ mac: formatted, alreadyExists: true }); + continue; + } + + const body = { + mac: formatted, + displayName: `Basestation ${formatted}` + }; + const res = await webex.request('POST', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations`, body); + logger('phone:provision', `Added basestation ${formatted}`, 'debug'); + results.push({ mac: formatted, success: true, id: res?.id }); + } catch (err) { + logger('phone:provision', `Add basestation ${formatted} failed: ${err.message}`, 'error'); + results.push({ mac: formatted, error: err.message }); + } + } + return results; +} + +/** + * Remove a basestation by ID (or MAC - will resolve). + */ +export async function removeDectBasestation(locationId, dectNetworkId, baseIdOrMac) { + if (!locationId || !dectNetworkId || !baseIdOrMac) { + throw new Error('locationId, networkId and base id/mac required'); + } + + let baseId = baseIdOrMac; + + // If looks like MAC, resolve to id + if (/^[0-9a-fA-F:.-]{12,17}$/.test(String(baseIdOrMac))) { + const clean = String(baseIdOrMac).replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + const bases = await getDectBasestations(locationId, dectNetworkId); + const found = bases.find(b => { + const bm = String(b.mac || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + return bm === clean; + }); + if (!found) throw new Error(`Basestation with MAC ${baseIdOrMac} not found`); + baseId = found.id; + } + + try { + await webex.request('DELETE', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/baseStations/${baseId}`); + logger('phone:provision', `Removed basestation ${baseId}`, 'debug'); + return { success: true, id: baseId }; + } catch (err) { + logger('phone:provision', `Remove basestation failed: ${err.message}`, 'error'); + throw err; + } +} + +/** + * Add a handset. + * Idempotent check on accessCode. + */ +export async function addDectHandset(locationId, dectNetworkId, { displayName, accessCode, baseStationId }) { + if (!locationId || !dectNetworkId || !accessCode) { + throw new Error('locationId, networkId and accessCode required'); + } + + const code = String(accessCode).trim(); + if (!/^\d{4}$/.test(code)) { + throw new Error('Access code must be exactly 4 digits'); + } + + try { + // Idempotency + const existing = await getDectHandsets(locationId, dectNetworkId); + if (existing.some(h => (h.extension || h.accessCode) === code)) { + logger('phone:provision', `Handset with accessCode ${code} already exists`, 'debug'); + return { alreadyExists: true }; + } + + const body = { + defaultDisplayName: displayName || code, + accessCode: code, + lines: [ + { + index: 1, + extension: code + } + ] + }; + if (baseStationId) { + body.baseStationId = baseStationId; + } + + const res = await webex.request('POST', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets`, body); + logger('phone:provision', `Added handset ${code}${baseStationId ? ` to base ${baseStationId}` : ''}`, 'debug'); + return res; + } catch (err) { + logger('phone:provision', `Add handset ${code} failed: ${err.message}`, 'error'); + throw err; + } +} + +/** + * Remove handset by id. + */ +export async function removeDectHandset(locationId, dectNetworkId, handsetId) { + if (!locationId || !dectNetworkId || !handsetId) { + throw new Error('locationId, networkId and handsetId required'); + } + + try { + await webex.request('DELETE', `telephony/config/locations/${locationId}/dectNetworks/${dectNetworkId}/handsets/${handsetId}`); + logger('phone:provision', `Removed handset ${handsetId}`, 'debug'); + return { success: true, id: handsetId }; + } catch (err) { + logger('phone:provision', `Remove handset failed: ${err.message}`, 'error'); + throw err; + } +} \ No newline at end of file diff --git a/services/renderers/avStatusRenderer.js b/services/renderers/avStatusRenderer.js new file mode 100644 index 0000000..2a78c03 --- /dev/null +++ b/services/renderers/avStatusRenderer.js @@ -0,0 +1,257 @@ +// src/services/renderers/avStatusRenderer.js +// +// Extracted from commands/avStatus.js so the same output can drive +// BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller +// comment (via utils/markdownToAdf). Byte-for-byte identical to what +// the chat command used to emit for a given input. +// +// Options +// storeNum (required) header string uses it +// detailed default true — historically the chat handler always ran +// detailed (the `Mode: detailed` string is hard-coded in +// the header). We keep the option in the signature for +// symmetry with the phone renderer and future compact-mode +// work. +// footer default true — appends `*Last checked: HH:MM*` italic +// line. Poller passes false because a Jira comment already +// carries an authoritative header timestamp. +// +// `appendMerakiClientLines` is preserved verbatim from the original +// handler — same wired-vs-wireless branch, same double-arrow indent +// convention, same fallback for `{client:...}` vs flat shapes. + +import { simpleTimeAgo } from '../../utils/time.js'; + +/** + * Render an AV device-status markdown snapshot from a + * `collectDeviceStatus` result. + * + * @param {object} data collectDeviceStatus() output + * @param {object} opts + * @param {string} opts.storeNum store id (header text) + * @param {boolean} [opts.detailed=true] + * @param {boolean} [opts.footer=true] + * @returns {string} markdown, whitespace-trimmed and ready to send. + */ +export function renderAvStatusMarkdown(data, opts = {}) { + const { storeNum, footer = true } = opts; + // `detailed` is accepted for signature symmetry but the existing AV + // renderer has always emitted detailed output — see file header note. + // Explicit reference prevents accidental prop-name typos going silent. + void opts.detailed; + + const mdmDevices = data.mdm?.data || []; + const atlasData = data.atlas?.data || []; + + let reply = `**Device Status - Store ${storeNum}** (Mode: detailed)\n\n`; + + // Helper: append Meraki client info under the device name. Preserved + // verbatim from commands/avStatus.js so wired-vs-wireless output stays + // byte-identical. + function appendMerakiClientLines(currentReply, mInput) { + if (!mInput) return currentReply; + const m = mInput.client || mInput; + if (!m || Object.keys(m).length === 0) return currentReply; + + const clientSeenHours = m.lastSeen ? (Date.now() - new Date(m.lastSeen).getTime()) / (1000 * 60 * 60) : 999; + const clientEmoji = clientSeenHours > 1 ? '⚠️' : '✅'; + + let clientLink = ''; + const clientUrl = m.clientUrl || m.merakiClientUrl; + if (clientUrl) { + clientLink = ` [Meraki↗](${clientUrl})`; + } else if (m.clientId && m.networkBaseUrl) { + const base = m.networkBaseUrl.replace(/\/manage\/clients$/, ''); + const merakiClientUrl = `${base}/manage/clients/${m.clientId}/overview`; + clientLink = ` [Meraki↗](${merakiClientUrl})`; + } + + const ip = m.ip ? `IP: ${m.ip}` : ''; + const mac = m.mac ? `MAC: ${m.mac}` : ''; + const last = m.lastSeen ? `• ${simpleTimeAgo(m.lastSeen)}` : ''; + const conn = m.recentDeviceConnection || 'Wired'; + const cstatus = m.clientStatus || m.status || '—'; + const devName = m.deviceName || m.name || ''; + + const isWired = conn === 'Wired' || !!m.portNumber || !!m.switchport || !!m.port; + + if (isWired && devName) { + currentReply += ` → ${clientEmoji} **${devName} (${conn} - ${cstatus})** • ${ip} • ${mac} ${last ? `• ${last}` : ''}${clientLink}\n`; + + const portNum = m.portNumber || m.switchport || m.port || '—'; + const vlan = m.dataVlan || m.vlan || '—'; + const voiceVlan = m.voiceVlan ? `Voice VLAN: ${m.voiceVlan}` : ''; + const portType = m.type ? m.type : ''; + const portName = m.portName ? m.portName : ''; + const status = m.status || (m.enabled ? 'Enabled' : 'Disabled'); + + let portLine = ` → → Port: **${portNum}**`; + if (portType) portLine += ` • Type: ${portType}`; + if (portName) portLine += ` • Name: ${portName}`; + portLine += ` • VLAN: ${vlan}`; + if (voiceVlan) portLine += ` • ${voiceVlan}`; + portLine += ` • ${status}\n`; + currentReply += portLine; + + const policy = m.accessPolicy || '—'; + const stickyCount = m.allowedMacs?.length || 0; + const stickyText = stickyCount > 0 ? `Sticky MAC (${stickyCount})` : '—'; + const poeText = m.poeEnabled === true ? '✅ POE On' : (m.poeEnabled === false ? 'POE Off' : '—'); + const errors = m.errors?.length > 0 ? `⚠️ Errors: ${m.errors.join(', ')}` : ''; + + currentReply += ` → → Policy: ${policy} • ${stickyText} • ${poeText} ${errors ? `• ${errors}` : ''}\n`; + } else { + currentReply += ` → ${clientEmoji} **${devName || 'Wireless'} (${m.recentDeviceConnection || 'Wireless'} - ${cstatus})** IP: ${m.ip || '—'} • MAC: ${m.mac || '—'} ${m.lastSeen ? `• ${simpleTimeAgo(m.lastSeen)}` : ''}${clientLink}\n`; + currentReply += ` → → SSID: ${m.ssid || '—'} • VLAN: ${m.vlan || '—'}\n`; + } + + return currentReply; + } + + if (mdmDevices.length === 0) { + reply += 'No MDM devices found for this store.\n'; + } else { + mdmDevices.forEach(dev => { + const mdmName = dev.friendlyName || dev.name || 'Unknown Device'; + const mdmLastSeenRaw = dev.lastSeen || dev.LastSeen || dev.LastSystemSampleTime; + const mdmLastSeen = mdmLastSeenRaw ? simpleTimeAgo(mdmLastSeenRaw) : '—'; + const m = dev.meraki || {}; + + const mdmSeenHours = mdmLastSeenRaw ? (Date.now() - new Date(mdmLastSeenRaw).getTime()) / (1000 * 60 * 60) : 999; + const mdmEmoji = mdmSeenHours > 24 ? '❌' : '✅'; + + reply += `${mdmEmoji} **${mdmName}** Last seen: ${mdmLastSeen}\n`; + + reply = appendMerakiClientLines(reply, m); + + if (dev.red) { + const red = dev.red; + + const connectivity = red.Connectivity || '—'; + const deployment = red.DeploymentStatusName || '—'; + const stateTransition = red.StateTransitionStatus || '—'; + const lastPing = red.LastPingTimeUTC ? simpleTimeAgo(red.LastPingTimeUTC) : '—'; + const availability = red.AvailabilityStatus || '—'; + + const isLastPingOld = red.LastPingTimeUTC + ? (Date.now() - new Date(red.LastPingTimeUTC).getTime()) / (1000 * 60 * 60) > 1 + : true; + + const hasProblem = + isLastPingOld || + stateTransition !== 'Current' || + availability !== 'Available' || + deployment !== 'PROCESSED'; + + const redEmoji = hasProblem ? '⚠️' : '✅'; + + reply += ` → ${redEmoji} **RED ${connectivity}** • Availability: ${availability} • Deployment: ${deployment} • State: ${stateTransition} • Last Ping: ${lastPing}\n`; + } + + if (dev.optisigns) { + const o = dev.optisigns; + const optiEmoji = o.isOld ? '⚠️' : '✅'; + reply += ` → ${optiEmoji} **OptiSigns** ${o.content} • Last heartbeat: ${o.lastHeartBeat ? simpleTimeAgo(o.lastHeartBeat) : '—'}\n`; + } + + reply += '\n'; + }); + } + + if (atlasData && atlasData.length > 0) { + reply += `**Atlas Devices:**\n\n`; + + atlasData.forEach(dev => { + const name = dev.name || dev.displayName || 'US002477AMP'; + const status = (dev.status || dev.state?.status || 'unknown').toLowerCase(); + + const lastSeenTime = dev.last_seen_at || dev.lastSeen + ? new Date(dev.last_seen_at || dev.lastSeen).getTime() + : 0; + const minutesAgo = (Date.now() - lastSeenTime) / (1000 * 60); + + const voltage = parseFloat(dev.state?.voltageMonitor) || 120; + const voltageProblem = Math.abs(voltage - 120) > 8; + + const faultStatus = dev.state?.faultStatus || 0; + const hasFault = faultStatus > 0; + + const ampsNotReady = dev.state + ? Object.keys(dev.state).some(k => + (k.startsWith('ampStatus_') || k.startsWith('ampModuleStatus_')) && + !['Ready', 'Active', 'Standby'].includes(dev.state[k]) + ) + : false; + + const hasProblem = minutesAgo > 20 || status !== 'online' || voltageProblem || hasFault || ampsNotReady; + const emoji = hasProblem ? '⚠️' : '✅'; + + const lastSeenStr = dev.last_seen_at || dev.lastSeen + ? simpleTimeAgo(dev.last_seen_at || dev.lastSeen) + : '—'; + reply += `${emoji} **${name}** (${status}) • Last seen: ${lastSeenStr}\n`; + + const m = dev.meraki || {}; + reply = appendMerakiClientLines(reply, m); + + const modelInfo = []; + if (dev.model?.name) modelInfo.push(dev.model.name); + if (dev.firmware?.version) modelInfo.push(`FW ${dev.firmware.version}`); + if (dev.sn || dev.serial) modelInfo.push(`SN ${dev.sn || dev.serial}`); + if (dev.state?.IpAddress) modelInfo.push(`IP ${dev.state.IpAddress}`); + + if (modelInfo.length > 0) { + reply += ` → ${modelInfo.join(' • ')}\n`; + } + + if (dev.state) { + const s = dev.state; + const cpuF = s.tempCpu ? Math.round((parseFloat(s.tempCpu) * 9 / 5) + 32) : '—'; + const psuF = s.tempPsu ? Math.round((parseFloat(s.tempPsu) * 9 / 5) + 32) : '—'; + const ioF = s.tempIo ? Math.round((parseFloat(s.tempIo) * 9 / 5) + 32) : '—'; + + reply += ` → CPU: ${cpuF}°F • PSU: ${psuF}°F • Io: ${ioF}°F • Voltage: ${voltage}V • Fan: ${s.fanSpeed ? Math.round(s.fanSpeed) : '—'}%\n`; + } + + if (dev.state) { + const s = dev.state; + let ampInfo = []; + for (let i = 1; i <= 8; i++) { + const statusKey = `ampStatus_${i}`; + const moduleKey = `ampModuleStatus_${i}`; + const ampStatus = s[statusKey] || s[moduleKey]; + if (ampStatus) { + ampInfo.push(`Amp${i}: ${ampStatus}`); + } + } + if (ampInfo.length > 0) { + reply += ` → Amps: ${ampInfo.join(', ')}\n`; + } + } + + if (hasFault) { + reply += ` → ⚠️ Fault Status: ${faultStatus}\n`; + } + + if (dev.state?.lastLogEntry) { + let logMessage = dev.state.lastLogEntry; + if (logMessage.includes("System startup from power connection")) { + logMessage = "System startup (power cycle)"; + } + const logTime = dev.state.lastLogEntry.includes("2025-Nov-04") + ? simpleTimeAgo("2025-11-04T15:51:26Z") + : "recent"; + + reply += ` → Last Log: ${logMessage} (${logTime})\n`; + } + + reply += '\n'; + }); + } + + if (footer) { + reply += `*Last checked: ${new Date().toLocaleTimeString()}*`; + } + + return reply.trim(); +} diff --git a/services/renderers/phoneStatusRenderer.js b/services/renderers/phoneStatusRenderer.js new file mode 100644 index 0000000..9cff50c --- /dev/null +++ b/services/renderers/phoneStatusRenderer.js @@ -0,0 +1,182 @@ +// src/services/renderers/phoneStatusRenderer.js +// +// Extracted from commands/phoneStatus.js so the same output can drive +// BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller +// comment (via utils/markdownToAdf). Byte-for-byte identical to what +// the chat command used to emit for a given input. +// +// Options +// storeNum (required) header string uses it +// detailed default false — mirrors chat's `?detailed=true` toggle: +// adds SIP URLs / alt SIPs, more port info, PoE state, etc. +// footer default true — appends the `*Last checked: HH:MM*` +// italic line. Chat passes true (unchanged). Poller passes +// false because a Jira comment already has an authoritative +// header timestamp and a Jira-side comment `created` field. +// +// The renderer is pure (no I/O). All time-derived output goes through +// `simpleTimeAgo` — the same helper the chat handler used, so relative +// times ("2h ago") stay consistent across chat and Jira surfaces. + +import { simpleTimeAgo, formatBytes } from '../../utils/time.js'; + +/** + * Render a phone-status markdown snapshot from a `collectPhoneStatus` + * result. + * + * @param {object} data collectPhoneStatus() output + * @param {object} opts + * @param {string} opts.storeNum 2-6 digit store id (header text) + * @param {boolean} [opts.detailed=false] + * @param {boolean} [opts.footer=true] + * @returns {string} markdown, whitespace-trimmed and ready to send. + */ +export function renderPhoneStatusMarkdown(data, opts = {}) { + const { storeNum, detailed = false, footer = true } = opts; + + let reply = `**Phone Status - Store ${storeNum}**\n\n`; + + const phones = data.phones?.data || []; + const dectBasestations = data.dectBasestations || []; + const dectHandsets = data.dectHandsets || []; + const dectNet = data.dectNetwork || null; + + if (phones.length === 0 && dectBasestations.length === 0) { + reply += 'No phones or DECT basestations found.\n'; + return reply.trim(); + } + + const prof = data.telephonyProfile || {}; + const pers = data.person || {}; + if (prof.timeZone) { + reply += `**Timezone:** ${prof.timeZone}\n`; + } + if (data.locationMainNumber) { + let extPart = ''; + if (pers.phoneNumbers && pers.phoneNumbers.length > 0) { + const nums = pers.phoneNumbers.map(n => n.value || n).filter(Boolean); + if (nums.length > 0) { + extPart = ` (${nums.join(', ')})`; + } + } + reply += `**PhoneNumber:** ${data.locationMainNumber}${extPart}\n\n`; + } + if (detailed && prof.outgoingPermission) { + const op = prof.outgoingPermission; + const mode = op.useCustomEnabled ? 'custom rules' : 'default'; + const ruleCount = op.callingPermissions ? op.callingPermissions.length : 0; + reply += `Outgoing: ${mode} (${ruleCount} permission entries)\n\n`; + } + + // Desk Phones + if (phones.length > 0) { + reply += '**Desk Phones:**\n'; + phones.forEach(phone => { + const lastSeen = simpleTimeAgo(phone.lastSeen) || 'unknown'; + let prefix = '✅ '; + if (phone.status !== 'connected') prefix = '⚠️ '; + + reply += `${prefix}**${phone.displayName || phone.name || 'Unknown Phone'}** (${phone.status}) Last seen: ${lastSeen}\n`; + + const fw = phone.firmware && phone.firmware !== '—' ? ` FW: ${phone.firmware}` : ''; + const ser = phone.serial && phone.serial !== '—' ? ` Serial: ${phone.serial}` : ''; + if (fw || ser) { + reply += ` ${fw}${ser ? (fw ? ' •' : '') + ser : ''}\n`; + } + if (detailed && phone.primarySipUrl && phone.primarySipUrl !== '—') { + reply += ` SIP: ${phone.primarySipUrl}\n`; + } + if (detailed && phone.sipUrls && phone.sipUrls.length > 1) { + reply += ` Alt SIPs: ${phone.sipUrls.slice(0, 2).join(', ')}${phone.sipUrls.length > 2 ? '…' : ''}\n`; + } + if (phone.errorCodes && phone.errorCodes.length > 0) { + reply += ` ⚠️ Errors: ${phone.errorCodes.join(', ')}\n`; + } + + if (phone.meraki && (phone.meraki.port || phone.meraki.switchName)) { + let mPrefix = ''; + if (phone.meraki.status !== 'Online') mPrefix = '⚠️ '; + reply += ` •${mPrefix}**${phone.meraki.switchName || 'Unknown Switch'}** (${phone.meraki.status || 'unknown'}) ` + + `Wired • Port: ${phone.meraki.port || '—'} • VLAN: ${phone.meraki.vlan || '—'} ` + + `• IP: ${phone.meraki.ip || '—'} LastSeen: ${simpleTimeAgo(phone.meraki.lastSeen)}${phone.meraki.clientUrl ? ` [Meraki↗](${phone.meraki.clientUrl})` : ''}\n`; + + const u = phone.meraki.usage; + if (u && (u.sent || u.recv || u.total)) { + const sent = formatBytes(u.sent || 0); + const recv = formatBytes(u.recv || 0); + const tot = u.total ? formatBytes(u.total) : ''; + reply += ` Data (recent): ${sent} sent / ${recv} recv${tot ? ' (total ' + tot + ')' : ''}\n`; + } + if (detailed) { + const poe = phone.meraki.poeEnabled != null ? (phone.meraki.poeEnabled ? 'PoE on' : 'PoE off') : ''; + const spd = phone.meraki.speed ? `${phone.meraki.speed}` : ''; + const pol = phone.meraki.portName ? `port ${phone.meraki.portName}` : ''; + const extras = [poe, spd, pol].filter(Boolean).join(' • '); + if (extras) reply += ` ${extras}\n`; + if (phone.meraki.accessPolicy) reply += ` Policy: ${phone.meraki.accessPolicy}\n`; + } + } else if (detailed) { + reply += ` (no recent Meraki client/switch data)\n`; + } + reply += '\n'; + }); + } + + // DECT Basestations + if (dectBasestations.length > 0) { + reply += '**DECT Basestations:**\n'; + if (dectNet) { + reply += `**Network:** ${dectNet.name || '—'} (assigned handsets: ${dectNet.handsetsCount || 0})\n`; + } + dectBasestations.forEach(base => { + let prefix = '✅ '; + if (base.meraki?.status !== 'Online') prefix = '⚠️ '; + + const lines = base.linesRegistered != null ? ` (lines: ${base.linesRegistered})` : ''; + reply += `${prefix}**Basestation ${base.mac || 'Unknown'}**${lines}\n`; + + if (base.meraki && (base.meraki.port || base.meraki.switchName)) { + let mPrefix = ''; + if (base.meraki.status !== 'Online') mPrefix = '⚠️ '; + reply += ` •${mPrefix}**${base.meraki.switchName || 'Unknown Switch'}** (${base.meraki.status || 'unknown'}) ` + + `Wired • Port: ${base.meraki.port || '—'} • VLAN: ${base.meraki.vlan || '—'} ` + + `• IP: ${base.meraki.ip || '—'} LastSeen: ${simpleTimeAgo(base.meraki.lastSeen)}${base.meraki.clientUrl ? ` [Meraki↗](${base.meraki.clientUrl})` : ''}\n`; + if (detailed && base.meraki.usage) { + const u = base.meraki.usage; + reply += ` Data (recent): ${formatBytes(u.sent || 0)} sent / ${formatBytes(u.recv || 0)} recv\n`; + } + } else { + reply += ` (no recent Meraki client/switch data — possibly offline or not attached to this network)\n`; + } + + const registeredHandsets = dectHandsets.filter(h => h.baseStationId === base.id); + if (registeredHandsets.length > 0) { + registeredHandsets.forEach(h => { + reply += ` • **${h.index}-${h.name || 'Handset'}** (ext ${h.extension || '—'}) Registered: ${simpleTimeAgo(h.lastRegistrationTime)}\n`; + }); + } else { + reply += ` No handsets registered\n`; + } + reply += '\n'; + }); + + const unregisteredHandsets = dectHandsets.filter(h => !h.baseStationId); + if (unregisteredHandsets.length > 0) { + reply += '**Unregistered Handsets:**\n'; + unregisteredHandsets.forEach(h => { + reply += ` • **${h.index}-${h.name || 'Handset'}** (ext ${h.extension || '—'}) Registered: ${simpleTimeAgo(h.lastRegistrationTime)}\n`; + }); + reply += '\n'; + } + } + + if (detailed) { + reply += `_Detailed mode — additional fields above (use without ?detailed=true for compact view)_\n`; + } + + if (footer) { + reply += `\n*Last checked: ${new Date().toLocaleTimeString()}*`; + } + + return reply.trim(); +} diff --git a/services/ticketClassifier.js b/services/ticketClassifier.js new file mode 100644 index 0000000..0666472 --- /dev/null +++ b/services/ticketClassifier.js @@ -0,0 +1,245 @@ +// src/services/ticketClassifier.js +// +// AI-based classifier for Jira poller. Reads a single Jira issue and +// returns a strict `{ kind, storeNum, reason, tokensUsed }` shape. +// +// Design decisions (locked with the operator up front): +// - Full-auto: whatever the model returns is what runs. No confidence +// gate. The Webex summary post exposes the model's `reason` per +// ticket so a human can spot-check bad classifications. +// - Skip-until-recovery: any failure (network, timeout, malformed +// JSON, missing required field) throws `TicketClassifierError`. +// The poller's per-ticket try/catch catches it, logs, and skips +// without labeling — that ticket comes back next hour. +// - Closed output taxonomy: kind ∈ {'phone', 'av', 'skip'}. Not +// 'unknown', not 'other'. Every ticket falls into one bucket, and +// 'skip' is a valid, safe non-action. +// - JSON mode (`response_format: json_object`) is on so we don't fight +// markdown-wrapped output. Fallback: `parseAndValidate` still +// tolerates fenced code blocks if the model ignores the hint. +// +// Store Number extraction: because your Jira's Store Number custom +// field is an opaque Atlassian Assets object reference (see comment on +// the poller's `extractStore` for context), the AI is the ONLY reliable +// way to get a numeric store id. The prompt tells the model to look at +// summary + description, where techs consistently type "Store 3860". + +import { callGrok } from '../utils/grokClient.js'; +import { logger } from '../utils/logger.js'; + +const MAX_DESCRIPTION_CHARS = 2000; +const MAX_REASON_CHARS = 200; +const VALID_KINDS = new Set(['phone', 'av', 'skip']); + +// Optional per-call model override. When unset, defers to XAI_MODEL +// via callGrok. Classification is a small structured task that runs +// well on a cheaper/faster model than long-form summaries. +const CLASSIFIER_MODEL = process.env.JIRA_POLLER_MODEL || undefined; + +const SYSTEM_PROMPT = + "You are triaging unassigned IT support tickets at American Eagle Outfitters (retail stores + corporate).\n" + + "\n" + + "Classify the ticket into EXACTLY one category:\n" + + '- "phone": SIP desk phones, DECT wireless handsets/basestations, extension routing,\n' + + " voicemail, dial tone, call quality, telephony provisioning, Webex Calling on desk\n" + + " phones. NOT general Webex account access.\n" + + '- "av": Cisco Webex Room devices, cameras, microphones, digital signage,\n' + + " audio amplifiers, in-store music playback, conference-room A/V technology.\n" + + '- "skip": anything else — laptops, mobile devices, printers, iPhones/iPads,\n' + + " account/access requests, Webex account requests (unrelated to phones),\n" + + " general networking, hardware orders, non-technical requests, or store\n" + + " requests we don't have a matching tool for yet.\n" + + "\n" + + "Extract a store number ONLY if the ticket is store-scoped. Store numbers are\n" + + "2-6 digit integers and usually appear in the summary as 'Store NNNN - ...' but\n" + + "may also be in the description or the 'Store Number' field. If the ticket is\n" + + "not clearly for a specific retail store, return null.\n" + + "\n" + + "Respond with a SINGLE JSON object matching this schema EXACTLY (no markdown,\n" + + "no code fences, no extra keys, no prose before or after):\n" + + '{"kind":"phone"|"av"|"skip","storeNum":""|null,"reason":""}'; + +/** + * Custom error type so the poller's catch block can tell a classifier + * failure apart from a `collectPhoneStatus` / `collectDeviceStatus` + * failure — both should skip the ticket, but they have different + * operator-facing meanings in the log. + */ +export class TicketClassifierError extends Error { + constructor(message, cause) { + super(message); + this.name = 'TicketClassifierError'; + if (cause) this.cause = cause; + } +} + +/** + * Build the per-ticket user message. Kept as a pure helper so it can + * be unit-tested without a live X.AI call. + * + * The summary is intentionally NOT truncated — store numbers almost + * always live in the title and truncating there would defeat the whole + * point. Description gets a 2000-char cap to keep the payload cheap. + * + * The raw Store Number field value is included even though it's usually + * an opaque Assets reference — sometimes it IS a plain string on other + * tenants / older tickets, and giving the model a chance to use it + * doesn't cost anything. + */ +export function buildUserMessage(ticket) { + const key = ticket?.key || '(unknown)'; + const summary = ticket?.summary || '(no summary)'; + const description = truncate(ticket?.description || '', MAX_DESCRIPTION_CHARS); + const components = Array.isArray(ticket?.components) + ? ticket.components.map((c) => c?.name).filter(Boolean).join(', ') + : ''; + const status = ticket?.status || ''; + const reporter = ticket?.reporter || ''; + const storeFieldRaw = ticket?.storeFieldRaw; + + let storeFieldDisplay = '(empty)'; + if (storeFieldRaw !== null && storeFieldRaw !== undefined && storeFieldRaw !== '') { + if (typeof storeFieldRaw === 'object') { + // Compact JSON so the model isn't drowning in whitespace but still + // sees whatever primitive fell out of the Jira response. + storeFieldDisplay = JSON.stringify(storeFieldRaw); + } else { + storeFieldDisplay = String(storeFieldRaw); + } + } + + return ( + `Ticket: ${key}\n` + + `Component(s): ${components || '(none)'}\n` + + `Status: ${status || '(none)'}\n` + + `Store Number field: ${storeFieldDisplay}\n` + + `Reporter: ${reporter || '(unknown)'}\n` + + `\n` + + `Summary:\n${summary}\n` + + `\n` + + `Description:\n${description || '(none)'}` + ); +} + +/** + * Parse and validate the model's response. Handles: + * - naked JSON object (JSON-mode happy path) + * - JSON object wrapped in ```json ... ``` fences (model ignoring hint) + * - leading/trailing prose + * + * Throws `TicketClassifierError` on any shape violation. + */ +export function parseAndValidate(rawContent) { + if (typeof rawContent !== 'string' || rawContent.trim() === '') { + throw new TicketClassifierError('empty response from model'); + } + + // Strip optional markdown code fences the model might sneak in. + const cleaned = rawContent + .trim() + .replace(/^```(?:json)?\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); + + // Extract the first {...} block if there's stray prose around it. + const firstBrace = cleaned.indexOf('{'); + const lastBrace = cleaned.lastIndexOf('}'); + if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) { + throw new TicketClassifierError(`no JSON object found in response: ${cleaned.slice(0, 120)}`); + } + const jsonSlice = cleaned.slice(firstBrace, lastBrace + 1); + + let obj; + try { + obj = JSON.parse(jsonSlice); + } catch (err) { + throw new TicketClassifierError(`JSON.parse failed: ${err.message} (raw: ${jsonSlice.slice(0, 120)})`); + } + + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { + throw new TicketClassifierError(`response is not a JSON object (got ${Array.isArray(obj) ? 'array' : typeof obj})`); + } + + if (!VALID_KINDS.has(obj.kind)) { + throw new TicketClassifierError(`invalid or missing 'kind' (got ${JSON.stringify(obj.kind)}, expected phone|av|skip)`); + } + + let storeNum = obj.storeNum; + if (storeNum === undefined) { + throw new TicketClassifierError("missing 'storeNum' field (must be a string of digits or null)"); + } + if (storeNum !== null) { + // Accept numbers too — some models can't help themselves and + // return `"storeNum": 3860` instead of `"3860"`. + const asString = String(storeNum).trim(); + if (!/^\d{2,6}$/.test(asString)) { + throw new TicketClassifierError(`invalid 'storeNum' (got ${JSON.stringify(storeNum)}, expected 2-6 digit string or null)`); + } + storeNum = asString; + } + + const reason = typeof obj.reason === 'string' ? obj.reason.trim() : ''; + if (!reason) { + throw new TicketClassifierError("missing or empty 'reason' string"); + } + const truncatedReason = reason.length > MAX_REASON_CHARS ? reason.slice(0, MAX_REASON_CHARS - 1) + '…' : reason; + + return { kind: obj.kind, storeNum, reason: truncatedReason }; +} + +/** + * Classify a single Jira ticket via X.AI. + * + * @param {object} ticket + * @param {string} ticket.key + * @param {string} ticket.summary + * @param {string} [ticket.description] + * @param {Array<{name: string}>} [ticket.components] + * @param {string} [ticket.status] + * @param {string} [ticket.reporter] + * @param {*} [ticket.storeFieldRaw] Raw value of the Store Number custom field. + * + * @returns {Promise<{ kind: 'phone'|'av'|'skip', storeNum: string|null, reason: string, tokensUsed: number }>} + * @throws {TicketClassifierError} on any failure — network, timeout, malformed JSON, invalid shape. + */ +export async function classifyTicket(ticket) { + const userMessage = buildUserMessage(ticket); + + let response; + try { + response = await callGrok(userMessage, { + system: SYSTEM_PROMPT, + response_format: { type: 'json_object' }, + model: CLASSIFIER_MODEL, + // Classification wants determinism, not creativity. + temperature: 0.0, + // JSON payloads for our schema are ~50 tokens — 200 is plenty of headroom. + max_tokens: 200, + // Slightly tighter than the default 15s: classification is a small + // structured task, and long hangs starve the hourly cron. + timeout: 10000, + includeUsage: true, + }); + } catch (err) { + throw new TicketClassifierError(`X.AI call failed: ${err.message}`, err); + } + + const parsed = parseAndValidate(response?.content || ''); + const tokensUsed = response?.usage?.total_tokens || 0; + + logger( + 'ticket:classifier', + `${ticket?.key || '?'}: kind=${parsed.kind} store=${parsed.storeNum || 'null'} tokens=${tokensUsed}`, + 'debug' + ); + + return { ...parsed, tokensUsed }; +} + +// Private helper — string truncation with an ellipsis. Also exported so +// smoke tests can exercise it if we ever add stronger edge-case cover. +function truncate(s, max) { + if (typeof s !== 'string') return ''; + if (s.length <= max) return s; + return s.slice(0, max - 1) + '…'; +} diff --git a/services/vcMonitorService.js b/services/vcMonitorService.js new file mode 100644 index 0000000..1f86f72 --- /dev/null +++ b/services/vcMonitorService.js @@ -0,0 +1,180 @@ +// services/vcMonitorService.js +// On-demand packet capture (ExtendedLogging + PacketDump) for Cisco RoomOS / Webex video endpoints. +// Uses cloud xAPI (requires spark:xapi_commands scope on the Service App / Integration). +// After capture, logs (containing the pcap) are retrieved from Control Hub diagnostics — not a direct file download. + +import webex from '../integrations/webex/WebexClient.js'; +import xapi from '../integrations/webex/XapiClient.js'; +import { logger } from '../utils/logger.js'; + +const VALID_PACKETDUMP = ['Full', 'Limited', 'FullRotate', 'None']; + +export async function startPacketCapture(bot, serialNumber, packetDump = 'Full') { + if (!serialNumber) throw new Error('Serial number is required'); + + const dumpType = normalizePacketDump(packetDump); + logger('vc-monitor', `Starting packet capture for serial ${serialNumber} (PacketDump=${dumpType})`); + + try { + await bot.say('markdown', `🚀 Starting extended logging + packet capture on **${serialNumber}** (PacketDump: **${dumpType}**)...`); + + // 1. Find device (same pattern as vcProvision) + const devicesResponse = await webex.request('GET', 'devices', null, { serial: serialNumber }); + + if (!devicesResponse.items || devicesResponse.items.length === 0) { + throw new Error(`No device found with serial ${serialNumber}`); + } + if (devicesResponse.items.length > 1) { + throw new Error(`Multiple devices found with serial ${serialNumber}`); + } + + const device = devicesResponse.items[0]; + const display = device.displayName || device.serial || serialNumber; + logger('vc-monitor', `Found device ${display} (${device.id})`); + + await bot.say('markdown', `✅ Device found: **${display}**`); + + // 2. Issue the xCommand + await bot.say('markdown', `📡 Issuing \`Logging ExtendedLogging Start PacketDump: ${dumpType}\` ...`); + + const result = await xapi.xCommandWithDevice( + 'Logging.ExtendedLogging.Start', + device.id, + { PacketDump: dumpType } + ); + + logger('vc-monitor', `xCommand result: ${JSON.stringify(result)}`); + + const durationHint = dumpType === 'Full' ? '~3 minutes (includes RTP/media)' + : dumpType === 'Limited' ? '~10 minutes (non-RTP/signaling)' + : dumpType === 'FullRotate' ? 'rolling (keeps recent ~1h worth)' + : 'no packet dump'; + + await bot.say('markdown', + `✅ **Capture started successfully**\n\n` + + `**Device:** ${display}\n` + + `**PacketDump:** ${dumpType} — ${durationHint}\n\n` + + `**Next steps (important):**\n` + + `1. Reproduce the problem **now** while the capture is running.\n` + + `2. When finished, run:\n` + + ` \`/vcMonitor ${serialNumber} stop\`\n` + + `3. Download the logs from **Control Hub**:\n` + + ` - Devices → find device → **Issues & Diagnostics** → **System Logs**\n` + + ` - Look for the most recent log bundle (the packet capture .pcap files are included, typically in the \`run/\` folder or a dedicated "Packet Captures" section).\n\n` + + `The capture will time out automatically, but stopping explicitly is recommended for a clean bundle.` + ); + + return { success: true, deviceId: device.id, dumpType, result }; + + } catch (error) { + const msg = error.response?.data?.message || error.message || 'Unknown error'; + logger('vc-monitor', `startPacketCapture failed for ${serialNumber}: ${msg}`, 'error'); + + // Common permission hint + if (msg.toLowerCase().includes('403') || msg.toLowerCase().includes('unauthorized') || msg.toLowerCase().includes('scope')) { + await bot.say('markdown', `⚠️ **Permission error** — does your Webex integration have the \`spark:xapi_commands\` scope?`); + } + + await bot.say('markdown', `❌ **Failed to start capture on ${serialNumber}**\n\n${msg}`); + throw error; + } +} + +export async function stopPacketCapture(bot, serialNumber) { + if (!serialNumber) throw new Error('Serial number is required'); + + logger('vc-monitor', `Stopping packet capture for serial ${serialNumber}`); + + try { + await bot.say('markdown', `🛑 Stopping extended logging + packet capture on **${serialNumber}**...`); + + const devicesResponse = await webex.request('GET', 'devices', null, { serial: serialNumber }); + if (!devicesResponse.items || devicesResponse.items.length === 0) { + throw new Error(`No device found with serial ${serialNumber}`); + } + const device = devicesResponse.items[0]; + const display = device.displayName || device.serial || serialNumber; + + const result = await xapi.xCommandWithDevice('Logging.ExtendedLogging.Stop', device.id, {}); + logger('vc-monitor', `Stop result: ${JSON.stringify(result)}`); + + await bot.say('markdown', + `✅ **Capture stopped** on **${display}**\n\n` + + `Now retrieve the logs (which include the packet captures):\n` + + `- Control Hub → Devices → select the device → **Issues & Diagnostics** → **System Logs** (download the latest bundle).\n` + + `- Or use the device's local web UI (Issues and Diagnostics) if you have direct access.\n\n` + + `Tip: Full bundles are usually what you want (they contain the PCAPs in the run/ directory).` + ); + + return { success: true, deviceId: device.id, result }; + + } catch (error) { + const msg = error.response?.data?.message || error.message || 'Unknown error'; + logger('vc-monitor', `stopPacketCapture failed for ${serialNumber}: ${msg}`, 'error'); + await bot.say('markdown', `❌ **Failed to stop capture on ${serialNumber}**\n\n${msg}`); + throw error; + } +} + +export async function getExtendedLoggingStatus(bot, serialNumber) { + if (!serialNumber) throw new Error('Serial number is required'); + + logger('vc-monitor', `Querying ExtendedLogging status for ${serialNumber}`); + + try { + await bot.say('markdown', `📡 Querying status on **${serialNumber}**...`); + + const devicesResponse = await webex.request('GET', 'devices', null, { serial: serialNumber }); + if (!devicesResponse.items || devicesResponse.items.length === 0) { + throw new Error(`No device found with serial ${serialNumber}`); + } + const device = devicesResponse.items[0]; + const display = device.displayName || device.serial || serialNumber; + + // Query the whole ExtendedLogging subtree + const status = await xapi.xStatus(device.id, 'Logging.ExtendedLogging'); + + logger('vc-monitor', `Status response keys: ${Object.keys(status || {})}`); + + // Try to surface the useful bits (structure can vary slightly by RoomOS version) + const ext = status?.result?.Logging?.ExtendedLogging || status?.result || {}; + const mode = ext.Mode || ext.mode || '—'; + const packetDump = ext.PacketDump || ext.packetDump || '—'; + + let summary = `**ExtendedLogging status for ${display}:**\n`; + summary += `- Mode: **${mode}**\n`; + summary += `- PacketDump: **${packetDump}**\n`; + + // If more detail is present (e.g. under PacketDump or files), surface a bit + if (ext.PacketDump && typeof ext.PacketDump === 'object') { + summary += `- Details: ${JSON.stringify(ext.PacketDump)}\n`; + } + + await bot.say('markdown', summary); + + // Also give a compact raw snippet for debugging (don't overwhelm) + const raw = JSON.stringify(status, null, 2); + const snippet = raw.length > 1200 ? raw.slice(0, 1200) + '\n... (truncated)' : raw; + await bot.say('markdown', `**Raw status (for diagnostics):**\n\`\`\`json\n${snippet}\n\`\`\``); + + return { success: true, deviceId: device.id, status, mode, packetDump }; + + } catch (error) { + const msg = error.response?.data?.message || error.message || 'Unknown error'; + logger('vc-monitor', `getExtendedLoggingStatus failed for ${serialNumber}: ${msg}`, 'error'); + await bot.say('markdown', `❌ **Failed to get status for ${serialNumber}**\n\n${msg}`); + throw error; + } +} + +function normalizePacketDump(input = 'Full') { + const s = String(input || '').trim(); + const match = VALID_PACKETDUMP.find(v => v.toLowerCase() === s.toLowerCase()); + return match || 'Full'; +} + +export default { + startPacketCapture, + stopPacketCapture, + getExtendedLoggingStatus +}; diff --git a/services/vcProvisionService.js b/services/vcProvisionService.js new file mode 100644 index 0000000..7be6383 --- /dev/null +++ b/services/vcProvisionService.js @@ -0,0 +1,469 @@ +// services/vcProvisionService.js +// Supports /provision-vc (renamed from /vcprovision) and multiple organizations via optional org param. +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import axios from 'axios'; + +import digicert from '../integrations/digicert/DigiCertClient.js'; +import webex from '../integrations/webex/WebexClient.js'; +import { logger } from '../utils/logger.js'; + +const execAsync = promisify(exec); +const ROOT_CERTS_PATH = './storage/aeoroots.cer'; + +/** + * Resolve org identifier (partial name or ID) to full org ID. + * Fetches from /organizations if needed. + */ +async function resolveOrgId(identifier) { + if (!identifier) return null; + + // If it looks like a full org ID, use as-is + if (identifier.startsWith('Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8')) { + return identifier; + } + + logger('vc-provision', `Resolving org identifier: ${identifier}`); + const orgsResponse = await webex.request('GET', 'organizations'); + const orgs = orgsResponse.items || []; + + const lowerId = identifier.toLowerCase(); + const match = orgs.find(org => + org.id === identifier || + org.displayName.toLowerCase().includes(lowerId) + ); + + if (!match) { + const available = orgs.map(o => `${o.displayName} (${o.id})`).join(', '); + throw new Error(`Organization not found for "${identifier}". Available: ${available}`); + } + + logger('vc-provision', `Resolved org: ${match.displayName} (${match.id})`); + return match.id; +} + +export async function provisionVideoDevice(bot, serialNumber, orgIdentifier = null) { + if (!serialNumber) throw new Error('Serial number is required'); + + const deviceName = serialNumber; + logger('vc-provision', `Starting full provisioning for ${deviceName}`); + + try { + await bot.say('markdown', `🚀 Starting provisioning for device **${deviceName}**...\nThis may take 3–6 minutes.`); + + // Resolve orgId if orgIdentifier provided (name or id) + let orgId = null; + if (orgIdentifier) { + orgId = await resolveOrgId(orgIdentifier); + await bot.say('markdown', `📍 Using organization: ${orgId}`); + } + + // 1. Find device + await bot.say('markdown', '🔍 Finding device in Webex...'); + const params = { serial: serialNumber }; + if (orgId) params.orgId = orgId; + const devicesResponse = await webex.request('GET', 'devices', null, params); + + if (!devicesResponse.items || devicesResponse.items.length === 0) { + throw new Error(`No device found with serial ${serialNumber}`); + } + if (devicesResponse.items.length > 1) { + throw new Error(`Multiple devices found with serial ${serialNumber}`); + } + + const device = devicesResponse.items[0]; + logger('vc-provision', `Found device: ${device.displayName || device.serial}`); + + await bot.say('markdown', `✅ Device found: **${device.displayName || device.serial}**`); + + // 2. Get location + await bot.say('markdown', '📍 Retrieving location details...'); + const location = await webex.request('GET', `locations/${device.locationId}`); + + // 3. Apply standard configuration FIRST + await bot.say('markdown', '⚙️ Applying standard configuration...'); + await applyStandardConfiguration(bot, device.id); + + // 4. Generate Private Key + CSR + await bot.say('markdown', '🔑 Generating private key and CSR...'); + const { privateKeyPem, csrPem, commonName } = await generateCSR(device, location); + const csrBase64 = csrPem + .replace(/-----BEGIN CERTIFICATE REQUEST-----/g, '') + .replace(/-----END CERTIFICATE REQUEST-----/g, '') + .replace(/\r?\n/g, '') + .trim(); + + // 5. Enroll with DigiCert + await bot.say('markdown', '📡 Submitting CSR to DigiCert...'); + const enrollResponse = await digicert.enrollCertificate(csrBase64, commonName, device.ip || '0.0.0.0'); + const requestId = enrollResponse.request_id; + + if (!requestId) throw new Error('No request_id returned from DigiCert'); + + // 6. Poll for certificate + await bot.say('markdown', '⏳ Waiting for certificate from DigiCert...'); + const certPem = await pollAndPickupCertificate(bot, requestId); + // 7. Apply certificates + await bot.say('markdown', '📥 Installing certificate...'); + await applyCertificatesToDevice(bot, device.id, privateKeyPem, certPem); + + // 8. Add backdoor admin account + await bot.say('markdown', '🔑 Adding backdoor admin account (`monitor`)...'); + await addBackdoorAdmin(bot, device.id); + + // 9. Final reboot + await bot.say('markdown', '🔄 Rebooting device...'); + await webex.request('POST', `xapi/command/SystemUnit.Boot`, { + deviceId: device.id, + arguments: { Action: 'Restart', Force: 'True' } + }); + + await bot.say('markdown', + `🎉 **Provisioning completed successfully!**\n\n` + + `**Device:** ${device.displayName || device.serial}\n` + + `**Common Name:** ${commonName}\n\n` + + `• Certificate installed and activated\n` + + `• Standard configuration applied\n` + + `• Backdoor admin account added\n` + + `• Device rebooted` + ); + + logger('vc-provision', `✅ Full provisioning completed for ${deviceName}`); + + } catch (error) { + logger('vc-provision', `Provisioning failed: ${error.message}`, 'error'); + await bot.say('markdown', `❌ **Provisioning failed**\n\n${error.message}`); + throw error; + } +} + +// ==================== STANDARD CONFIGURATION WITH 429 HANDLING ==================== +async function applyStandardConfiguration(bot, deviceId) { + const configSettings = [ + { path: "HttpClient.Mode", value: "On" }, + { path: "HttpClient.UseHttpProxy", value: "Off" }, + { path: "NetworkServices.Websocket", value: "FollowHTTPService" }, + { path: "Proximity.Services.CallControl", value: "Enabled" }, + { path: "RoomAnalytics.AmbientNoiseEstimation.Mode", value: "On" }, + { path: "RoomAnalytics.PeopleCountOutOfCall", value: "On" }, + { path: "RoomAnalytics.PeoplePresenceDetector", value: "On" }, + { path: "RoomAnalytics.ReverberationTime.Mode", value: "On" }, + { path: "Standby.Delay", value: 10 }, + { path: "Standby.Signage.Mode", value: "Off" }, + { path: "Standby.Signage.Url", value: "https://app.onfirstup.com/embed/9f3b4f1b-4acc-4076-aafe-42d09f3ed384" }, + { path: "Time.DateFormat", value: "MM_DD_YY" }, + { path: "Time.TimeFormat", value: "12H" }, + { path: "UserInterface.Features.Call.JoinGoogleMeet", value: "Auto" }, + { path: "UserInterface.Features.Call.JoinMicrosoftTeamsDirectGuestJoin", value: "Auto" }, + { path: "UserInterface.Features.Call.JoinWebex", value: "Auto" }, + { path: "UserInterface.Features.Call.JoinZoom", value: "Auto" }, + { path: "UserInterface.Theme.Name", value: "Night" }, + { path: "WebEngine.Mode", value: "On" }, + { path: "WebEngine.MinimumTLSVersion", value: "TLSv1.2" }, + { path: "WebRTC.Provider.MicrosoftTeams.CompatibilityMode", value: "On" }, + // QoS + { path: "Network[1].QoS.Diffserv.Audio", value: 46 }, + { path: "Network[1].QoS.Diffserv.Data", value: 34 }, + { path: "Network[1].QoS.Diffserv.Signalling", value: 24 }, + { path: "Network[1].QoS.Diffserv.Video", value: 34 }, + ]; + + let successCount = 0; + + await bot.say('markdown', `⚙️ Applying **${configSettings.length}** configuration settings...`); + + for (const setting of configSettings) { + let attempt = 0; + const maxRetries = 3; + + while (attempt < maxRetries) { + try { + const patchOperation = { + op: "replace", + path: `${setting.path}/sources/configured/value`, + value: setting.value + }; + + await axios.patch( + `https://webexapis.com/v1/deviceConfigurations?deviceId=${deviceId}`, + patchOperation, + { + headers: { + 'Authorization': `Bearer ${await webex.auth.getAccessToken()}`, + 'Content-Type': 'application/json-patch+json', + 'Accept': 'application/json' + } + } + ); + + successCount++; + logger('vc-provision', `✓ ${setting.path} = ${setting.value}`); + break; // success → move to next setting + + } catch (err) { + if (err.response?.status === 429) { + attempt++; + const retryAfter = parseInt(err.response.headers['retry-after']) || 2; // seconds + logger('vc-provision', `429 rate limit hit on ${setting.path}. Waiting ${retryAfter}s before retry...`, 'warn'); + + await bot.say('markdown', `⏳ Rate limit hit. Waiting ${retryAfter}s before retrying...`); + + await new Promise(r => setTimeout(r, retryAfter * 1000)); + continue; // retry the same setting + } + + // Other errors (400, 403, etc.) + logger('vc-provision', `✗ Failed ${setting.path}: ${err.message}`, 'warn'); + break; // don't retry other error types + } + } + + // Small delay between settings to be gentle + await new Promise(r => setTimeout(r, 350)); + } + + await bot.say('markdown', `⚙️ Applied **${successCount}** configuration settings.`); +} + +// ==================== ADD BACKDOOR ADMIN ACCOUNT ==================== +async function addBackdoorAdmin(bot, deviceId) { + const username = process.env.BACKDOOR_USERNAME || 'monitor'; + const password = process.env.BACKDOOR_PASSWORD; + + if (!password) { + logger('vc-provision', 'BACKDOOR_PASSWORD not set in environment', 'warn'); + await bot.say('markdown', '⚠️ Backdoor account not added (password not configured)'); + return false; + } + + try { + await webex.request('POST', 'xapi/command/UserManagement.User.Add', { + deviceId, + arguments: { + Active: "True", + Passphrase: password, + PassphraseChangeRequired: "False", + Role: ["Admin", "Audit", "User", "Integrator", "RoomControl"], + ShellLogin: "True", + Username: username + } + }); + + logger('vc-provision', `✓ Backdoor admin account '${username}' added successfully`); + await bot.say('markdown', `🔑 Backdoor admin account (\`${username}\`) added successfully`); + return true; + + } catch (err) { + const errorMsg = err.response?.data?.message || err.message || 'Unknown error'; + logger('vc-provision', `✗ Failed to add backdoor admin: ${errorMsg}`, 'warn'); + + if (errorMsg.includes('User already exists') || errorMsg.includes('already exists')) { + await bot.say('markdown', `🔑 Backdoor admin account (\`${username}\`) **already exists** — skipping creation.`); + } else { + await bot.say('markdown', `⚠️ Could not add backdoor admin account: ${errorMsg}`); + } + return false; + } +} + +// ==================== CSR GENERATION ==================== +async function generateCSR(device, location) { + logger('vc-provision', 'Generating 4096-bit RSA key pair and CSR...'); + + const commonName = `${device.serial}.aeo.ae.com`; + + let state = location.address?.state || 'Unknown'; + if (state === 'PA') state = 'Pennsylvania'; + else if (state === 'NY') state = 'New York'; + else if (state === 'OH') state = 'Ohio'; + else if (state === 'KS') state = 'Kansas'; + + const configContent = ` +[req] +prompt = no +distinguished_name = dn +req_extensions = ext + +[dn] +CN = ${commonName} +C = ${location.address?.country || 'US'} +ST = ${state} +L = ${location.address?.city || 'Unknown'} +O = American Eagle Outfitters +OU = Tech-Collaboration + +[ext] +subjectAltName = DNS:${commonName},IP:${device.ip || '0.0.0.0'} +`; + + const timestamp = Date.now(); + const keyPath = `/tmp/key_${timestamp}.pem`; + const configPath = `/tmp/csr_config_${timestamp}.conf`; + const csrPath = `/tmp/csr_${timestamp}.csr`; + + try { + fs.writeFileSync(configPath, configContent); + await execAsync(`openssl req -new -newkey rsa:4096 -nodes -keyout ${keyPath} -out ${csrPath} -config ${configPath}`); + + const privateKeyPem = fs.readFileSync(keyPath, 'utf8'); + const csrPem = fs.readFileSync(csrPath, 'utf8'); + + fs.unlinkSync(keyPath); + fs.unlinkSync(configPath); + fs.unlinkSync(csrPath); + + return { privateKeyPem, csrPem, commonName }; + } catch (err) { + logger('vc-provision', `Failed to generate CSR: ${err.message}`, 'error'); + throw new Error(`Failed to generate CSR: ${err.message}`); + } +} + +// ==================== CERTIFICATE PICKUP - LEAF ONLY ==================== +// ==================== CERTIFICATE PICKUP - ROBUST ==================== +async function pollAndPickupCertificate(bot, requestId) { + const maxAttempts = 60; // Increased slightly + const delayMs = 25000; // 25 seconds between attempts + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const data = await digicert.pickupCertificate(requestId); + + logger('vc-provision', `Pickup attempt ${attempt} - Raw response keys: ${Object.keys(data)}`); + + // Try multiple possible response formats DigiCert uses + let certText = ''; + + if (typeof data === 'string') { + certText = data; + } else if (data.certificate) { + certText = data.certificate; + } else if (data.pem) { + certText = data.pem; + } else if (data.cert) { + certText = data.cert; + } else if (data.body) { + certText = data.body; + } else if (data.Certificate) { + certText = data.Certificate; + } else { + // Last resort: stringify the whole response + certText = JSON.stringify(data); + } + + // Look for PEM certificate blocks + const pemRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g; + const matches = certText.match(pemRegex); + + if (matches && matches.length > 0) { + // Take ONLY the first (leaf) certificate + const leafCert = matches[0].trim() + '\n'; + + logger('vc-provision', `✅ Certificate retrieved on attempt ${attempt} (${matches.length} blocks found)`); + await bot.say('markdown', `✅ Certificate received from DigiCert on attempt ${attempt}`); + + return leafCert; + } + + logger('vc-provision', `Attempt ${attempt}: No PEM block found yet...`); + + } catch (err) { + const msg = err.response?.data?.message || err.message || 'Unknown error'; + + if (msg.toLowerCase().includes('not ready') || + err.response?.status === 404 || + msg.toLowerCase().includes('pending')) { + logger('vc-provision', `Attempt ${attempt}: Certificate not ready yet...`); + } else { + logger('vc-provision', `Certificate pickup error on attempt ${attempt}: ${msg}`, 'error'); + throw err; + } + } + + await new Promise(r => setTimeout(r, delayMs)); + } + + logger('vc-provision', 'Timeout waiting for certificate from DigiCert', 'error'); + throw new Error('Timeout waiting for certificate from DigiCert after ' + maxAttempts + ' attempts'); +} + +// ==================== APPLY CERTIFICATES ==================== +async function applyCertificatesToDevice(bot, deviceId, privateKeyPem, certPem) { + const rootCerts = fs.readFileSync(ROOT_CERTS_PATH, 'utf8').trim(); + + // 1. Upload Root / Intermediate CAs (safe to repeat) + try { + await webex.request('POST', `xapi/command/Security.Certificates.CA.Add`, { + deviceId, + body: rootCerts + }); + logger('vc-provision', 'Root CA certificates uploaded'); + } catch (e) { + logger('vc-provision', `Root CA upload skipped (may already exist): ${e.message}`, 'warn'); + } + + // 2. Clean Private Key + Leaf Certificate ONLY (no extra root) + const cleanKey = privateKeyPem.trim().replace(/\r\n/g, '\n'); + const cleanCert = certPem.trim().replace(/\r\n/g, '\n'); + + // CRITICAL: Private Key + Leaf Certificate with exactly ONE newline between them + const combinedPem = cleanKey + '\n' + cleanCert + '\n'; + + // NOTE: a debug dump of `combinedPem` to ./storage/ previously lived here + // and was removed because (a) the dump's `const debugPath` was commented + // out but the `logger(${debugPath})` line was not, producing a fatal + // ReferenceError mid-provision, and (b) `combinedPem` contains the device + // *private key* — writing it to disk is a credential-exfil risk that + // should not be enabled by default. If you need to re-introduce capture + // for debugging, gate the entire declaration + write + log behind a + // single `if (process.env.VC_DEBUG_DUMP_PEM === 'true') { … }` block so + // block-scope can't leak again, and prefer writing to `os.tmpdir()` with + // mode 0o600 over `./storage/`. + + await bot.say('markdown', '📤 Uploading device certificate (private key + leaf)...'); + + try { + await webex.request('POST', `xapi/command/Security.Certificates.Services.Add`, { + deviceId, + body: combinedPem + }); + + logger('vc-provision', '✅ Device certificate uploaded successfully'); + await bot.say('markdown', '✅ Device certificate uploaded successfully'); + + } catch (err) { + const errorMsg = err.response?.data?.message || err.message; + logger('vc-provision', `❌ Certificate upload failed: ${errorMsg}`, 'error'); + logger('vc-provision', `Combined PEM length: ${combinedPem.length}`, 'error'); + await bot.say('markdown', `❌ Certificate upload failed: ${errorMsg}`); + throw err; + } + + // 3. Activate for HTTPS and 802.1X + await bot.say('markdown', '🔄 Activating certificate for HTTPS and 802.1X...'); + + try { + const showResult = await webex.request('POST', `xapi/command/Security.Certificates.Services.Show`, { deviceId }); + const certs = showResult.result?.Details || []; + + for (const cert of certs) { + if (cert.IssuerName && cert.IssuerName.includes('Corporate 2022')) { + await webex.request('POST', `xapi/command/Security.Certificates.Services.Activate`, { + deviceId, + arguments: { Fingerprint: cert.Fingerprint, Purpose: 'HTTPS' } + }); + + await webex.request('POST', `xapi/command/Security.Certificates.Services.Activate`, { + deviceId, + arguments: { Fingerprint: cert.Fingerprint, Purpose: '802.1X' } + }); + + await bot.say('markdown', '✅ Certificate activated for HTTPS and 802.1X'); + break; + } + } + } catch (err) { + logger('vc-provision', `Activation warning: ${err.message}`, 'warn'); + } +} \ No newline at end of file diff --git a/services/webhookService.js b/services/webhookService.js new file mode 100644 index 0000000..14a0264 --- /dev/null +++ b/services/webhookService.js @@ -0,0 +1,35 @@ +// src/services/webhookService.js + +import { logger } from '../utils/logger.js'; + +/** + * Processes incoming ServiceChannel webhook payloads + * (currently just logs; real logic will be added next) + * @param {object} payload - the webhook body from ServiceChannel + */ +export async function processServiceChannelWebhook(payload) { + try { + logger('webhookService', `Received webhook payload: ${JSON.stringify(payload, null, 2)}`); + + const { Object: obj, EventType: eventType } = payload || {}; + + if (!obj?.Id) { + logger('webhookService', 'Invalid payload - missing Id'); + return; + } + + logger('webhookService', `Processing ${eventType} for WO-${obj.Number || obj.Id}`); + + // ── Future real logic will go here ── + // - Check/create Webex room + // - Build message + // - Post to Webex + // - Handle notes/attachments if needed + + logger('webhookService', `Webhook processed successfully`); + } catch (err) { + logger('webhookService', `Error processing webhook: ${err.message}`, 'error'); + } +} + +export default processServiceChannelWebhook; \ No newline at end of file diff --git a/services/woService.js b/services/woService.js new file mode 100644 index 0000000..2f7807e --- /dev/null +++ b/services/woService.js @@ -0,0 +1,99 @@ +// src/services/woService.js +import { + getWorkOrderDetails, + searchServiceChannelAVWorkOrders, + getWorkOrderNotes, + getWorkOrderAttachments +} from '../integrations/serviceChannel/client.js'; + +import { summarizeTicketWithGrok, summarizeWorkOrders } from '../utils/grokSummarizer.js'; +import { logger } from '../utils/logger.js'; + +export async function collectWoHistory(storeNumber) { + logger('wo:service', `Starting work order history collection for store ${storeNumber}`); + + try { + // Step 1: Fetch basic list of AV work orders + const rawWorkOrders = await searchServiceChannelAVWorkOrders(storeNumber); + + if (rawWorkOrders.length === 0) { + logger('wo:service', `No AV work orders found for store ${storeNumber}`); + return { workOrders: [], storeNumber, totalRecords: 0 }; + } + + logger('wo:service', `Found ${rawWorkOrders.length} raw AV work orders for store ${storeNumber}`); + + // Step 2: Enrich each WO with notes history + const workOrdersWithNotes = await Promise.all( + rawWorkOrders.map(async wo => { + const notes = await getWorkOrderNotes(wo.id); + return { ...wo, notes }; + }) + ); + + // Step 3: Bulk summarize with Grok + const summarizedWorkOrders = await summarizeWorkOrders(workOrdersWithNotes); + + logger('wo:service', `Summarized ${summarizedWorkOrders.length} work orders`); + + return { + workOrders: summarizedWorkOrders, + storeNumber, + totalRecords: rawWorkOrders.length + }; + + } catch (err) { + logger('wo:service', `Error collecting work orders for store ${storeNumber}: ${err.message}`, 'error'); + return { workOrders: [], storeNumber }; + } +} + +export async function collectWoSummary(woNumber) { + logger('wo:service', `Summarizing work order ${woNumber}`); + + try { + const ticket = await getWorkOrderDetails(woNumber); + if (!ticket) { + throw new Error('Work order not found'); + } + + const notes = await getWorkOrderNotes(woNumber); + + const grokSummary = await summarizeTicketWithGrok(ticket, notes); + + // Format final reply + let reply = `**Grok Summary:**\n${grokSummary}\n\n`; + reply += `Status: ${ticket.Status?.Primary || 'N/A'} • Trade: ${ticket.Trade || 'N/A'}\n`; + reply += `Opened: ${ticket.CallDate ? new Date(ticket.CallDate).toLocaleDateString() : 'N/A'}\n`; + reply += `Total Invoice Cost: $${ticket.Nte?.toLocaleString() || 'N/A'}\n`; + + logger('wo:service', `Successfully summarized WO ${woNumber}`); + return reply; + + } catch (err) { + logger('wo:service', `Error summarizing WO ${woNumber}: ${err.message}`, 'error'); + return `Unable to summarize work order ${woNumber}: ${err.message}`; + } +} + +/** + * Collect attachments for a single work order + */ +export async function collectWoAttachments(woNumber) { + logger('wo:service', `Collecting attachments for WO ${woNumber}`); + + try { + const attachments = await getWorkOrderAttachments(woNumber); + + logger('wo:service', `Found ${attachments.length} attachments for WO ${woNumber}`); + + return { + woNumber, + count: attachments.length, + attachments + }; + } catch (err) { + logger('wo:service', `Error collecting attachments for WO ${woNumber}: ${err.message}`, 'error'); + throw err; + } +} \ No newline at end of file diff --git a/tests/markdownToAdf.test.js b/tests/markdownToAdf.test.js new file mode 100644 index 0000000..49dad41 --- /dev/null +++ b/tests/markdownToAdf.test.js @@ -0,0 +1,166 @@ +// Unit tests for utils/markdownToAdf.js — uses Node's built-in test +// runner (`node --test tests/markdownToAdf.test.js`) so we don't need +// a separate test framework in package.json for this scope. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + markdownToAdf, + markdownToAdfContent, + tokenizeLine, +} from '../utils/markdownToAdf.js'; + +test('empty string produces an empty doc', () => { + assert.deepEqual(markdownToAdf(''), { version: 1, type: 'doc', content: [] }); + assert.deepEqual(markdownToAdfContent(''), []); +}); + +test('whitespace-only input produces an empty doc', () => { + assert.deepEqual(markdownToAdfContent(' \n\n '), []); +}); + +test('non-string input is treated as empty', () => { + assert.deepEqual(markdownToAdfContent(null), []); + assert.deepEqual(markdownToAdfContent(undefined), []); + assert.deepEqual(markdownToAdfContent(42), []); +}); + +test('plain single-paragraph text becomes one paragraph', () => { + const out = markdownToAdfContent('hello world'); + assert.deepEqual(out, [ + { type: 'paragraph', content: [{ type: 'text', text: 'hello world' }] }, + ]); +}); + +test('bold: **text**', () => { + assert.deepEqual(tokenizeLine('a **bold** b'), [ + { type: 'text', text: 'a ' }, + { type: 'text', text: 'bold', marks: [{ type: 'strong' }] }, + { type: 'text', text: ' b' }, + ]); +}); + +test('italic: *text*', () => { + assert.deepEqual(tokenizeLine('a *italic* b'), [ + { type: 'text', text: 'a ' }, + { type: 'text', text: 'italic', marks: [{ type: 'em' }] }, + { type: 'text', text: ' b' }, + ]); +}); + +test('link: [label](url)', () => { + assert.deepEqual(tokenizeLine('see [here](https://x.y)'), [ + { type: 'text', text: 'see ' }, + { + type: 'text', + text: 'here', + marks: [{ type: 'link', attrs: { href: 'https://x.y' } }], + }, + ]); +}); + +test('all three marks combined in one line', () => { + const out = tokenizeLine('**bold** and *em* and [click](https://x.y) done'); + assert.deepEqual(out, [ + { type: 'text', text: 'bold', marks: [{ type: 'strong' }] }, + { type: 'text', text: ' and ' }, + { type: 'text', text: 'em', marks: [{ type: 'em' }] }, + { type: 'text', text: ' and ' }, + { + type: 'text', + text: 'click', + marks: [{ type: 'link', attrs: { href: 'https://x.y' } }], + }, + { type: 'text', text: ' done' }, + ]); +}); + +test('emoji at line start survives as plain text', () => { + assert.deepEqual(tokenizeLine('✅ ok'), [ + { type: 'text', text: '✅ ok' }, + ]); +}); + +test('arrow → and bullet • characters are plain text', () => { + assert.deepEqual(tokenizeLine(' → foo • bar'), [ + { type: 'text', text: ' → foo • bar' }, + ]); +}); + +test('blank line splits into two paragraphs', () => { + const out = markdownToAdfContent('one\n\ntwo'); + assert.equal(out.length, 2); + assert.equal(out[0].type, 'paragraph'); + assert.deepEqual(out[0].content, [{ type: 'text', text: 'one' }]); + assert.deepEqual(out[1].content, [{ type: 'text', text: 'two' }]); +}); + +test('single newline becomes hardBreak inside one paragraph', () => { + const out = markdownToAdfContent('line1\nline2'); + assert.equal(out.length, 1); + assert.equal(out[0].type, 'paragraph'); + assert.deepEqual(out[0].content, [ + { type: 'text', text: 'line1' }, + { type: 'hardBreak' }, + { type: 'text', text: 'line2' }, + ]); +}); + +test('multiple blank lines collapse to a single paragraph split', () => { + const out = markdownToAdfContent('a\n\n\n\nb'); + assert.equal(out.length, 2); + assert.deepEqual(out[0].content, [{ type: 'text', text: 'a' }]); + assert.deepEqual(out[1].content, [{ type: 'text', text: 'b' }]); +}); + +test('malformed markdown does not throw', () => { + // Never throws is the only hard guarantee — the exact token shape + // for ambiguous fragments is best-effort. The parser scans strong + // before em, so an unclosed `**` frequently gets rescued as an em + // starting at the second `*`. That's fine, just verify the concrete + // behavior stays stable. + assert.doesNotThrow(() => tokenizeLine('**unclosed and *also* here')); + const out = tokenizeLine('**unclosed and *also* here'); + // Something got marked em — the middle `*...*` window matches. + assert.ok(out.some((n) => n.marks?.[0]?.type === 'em')); +}); + +test('empty link body [](url) is treated as plain text', () => { + // LINK_RE requires at least one char inside brackets; the empty case + // won't match, so the raw text passes through unmarked. + const out = tokenizeLine('a [](https://x.y) b'); + const rendered = out.map((n) => n.text).join(''); + assert.equal(rendered, 'a [](https://x.y) b'); + assert.ok(!out.some((n) => n.marks?.[0]?.type === 'link')); +}); + +test('*a**b*c does not throw and produces plausible tokens', () => { + // Nasty edge case — verify we don't crash. Exact shape is not + // contract, just "well-formed ADF inline nodes with no exception". + assert.doesNotThrow(() => tokenizeLine('*a**b*c')); +}); + +test('markdownToAdf full doc wraps content correctly', () => { + const doc = markdownToAdf('**hi**\n\n[go](https://x)'); + assert.equal(doc.version, 1); + assert.equal(doc.type, 'doc'); + assert.equal(doc.content.length, 2); + assert.deepEqual(doc.content[0].content, [ + { type: 'text', text: 'hi', marks: [{ type: 'strong' }] }, + ]); + assert.deepEqual(doc.content[1].content, [ + { + type: 'text', + text: 'go', + marks: [{ type: 'link', attrs: { href: 'https://x' } }], + }, + ]); +}); + +test('adjacent plain-text runs collapse into one text node', () => { + // Verifies the pushText coalescing behavior — cleaner ADF output. + const out = tokenizeLine('abc def ghi'); + assert.equal(out.length, 1); + assert.deepEqual(out[0], { type: 'text', text: 'abc def ghi' }); +}); diff --git a/tests/pollerAdf.test.js b/tests/pollerAdf.test.js new file mode 100644 index 0000000..b95109f --- /dev/null +++ b/tests/pollerAdf.test.js @@ -0,0 +1,122 @@ +// Integration smoke test for the poller's ADF assembly. +// +// We can't (and shouldn't) fire the whole `pollNewTickets` from a unit +// test — it needs Jira credentials, the AI classifier, and Webex. So +// we test the two composed pieces the plan says matter: (1) the new +// `buildAdfComment` shape, and (2) end-to-end that a Meraki `[..](..)` +// link the renderer emits actually survives the markdown → ADF trip +// and lands as a `link` mark in the poster body. +// +// `buildAdfComment` was extracted to utils/adfComment.js — a pure +// module with no side-effect imports — precisely so tests can pull it +// in without needing to satisfy Jira/Webex/Grok env vars at load time. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildAdfComment } from '../utils/adfComment.js'; +import { markdownToAdfContent } from '../utils/markdownToAdf.js'; +import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js'; + +test('buildAdfComment shape: italic header + spliced body nodes', () => { + const body = markdownToAdfContent('hello **bold** world'); + const doc = buildAdfComment({ + headerLine: 'Auto-enriched by CollabFinder — sample', + bodyNodes: body, + }); + + assert.equal(doc.version, 1); + assert.equal(doc.type, 'doc'); + assert.equal(doc.content.length, 2); + + const header = doc.content[0]; + assert.equal(header.type, 'paragraph'); + assert.equal(header.content[0].marks[0].type, 'em'); + assert.equal(header.content[0].text, 'Auto-enriched by CollabFinder — sample'); + + assert.equal(doc.content[1].type, 'paragraph'); + const strong = doc.content[1].content.find((n) => n.marks?.[0]?.type === 'strong'); + assert.ok(strong, 'expected a strong-marked text node in body'); + assert.equal(strong.text, 'bold'); +}); + +test('buildAdfComment tolerates empty bodyNodes', () => { + const doc = buildAdfComment({ headerLine: 'header only' }); + assert.equal(doc.content.length, 1); + assert.equal(doc.content[0].content[0].text, 'header only'); +}); + +test('end-to-end: Meraki [Meraki↗](url) survives to a link mark in ADF', () => { + const threeHrAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + const data = { + phones: { + data: [{ + displayName: 'PHONE 782-1', + status: 'connected', + lastSeen: threeHrAgo, + meraki: { + switchName: 'STORE-782-SW1', + status: 'Online', + port: '17', + vlan: '20', + ip: '10.1.1.5', + lastSeen: threeHrAgo, + clientUrl: 'https://n123.meraki.com/example/manage/clients/abc/overview', + }, + }], + }, + }; + + const markdown = renderPhoneStatusMarkdown(data, { + storeNum: '782', + detailed: true, + footer: false, + }); + const bodyNodes = markdownToAdfContent(markdown); + const doc = buildAdfComment({ headerLine: 'test', bodyNodes }); + + const allTextNodes = collectTextNodes(doc); + const linkNodes = allTextNodes.filter((n) => n.marks?.some((m) => m.type === 'link')); + + assert.ok(linkNodes.length > 0, 'expected at least one link mark in the ADF output'); + const merakiLink = linkNodes.find((n) => + n.marks.some((m) => + m.type === 'link' && + m.attrs?.href === 'https://n123.meraki.com/example/manage/clients/abc/overview', + ), + ); + assert.ok(merakiLink, 'expected the Meraki↗ URL to be preserved as a link mark'); + assert.equal(merakiLink.text, 'Meraki↗'); +}); + +test('end-to-end: bold device names survive to strong marks in ADF', () => { + const data = { + phones: { + data: [{ + displayName: 'PHONE 782-99', + status: 'connected', + lastSeen: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }], + }, + }; + const markdown = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); + const nodes = markdownToAdfContent(markdown); + const all = collectTextNodes({ content: nodes }); + const strongNames = all.filter((n) => n.marks?.some((m) => m.type === 'strong')); + assert.ok( + strongNames.some((n) => n.text === 'PHONE 782-99'), + 'expected device name to appear as strong-marked text', + ); +}); + +// Walk an ADF doc (or partial fragment) and return every leaf text node. +function collectTextNodes(node, acc = []) { + if (!node) return acc; + if (node.type === 'text') { + acc.push(node); + return acc; + } + const children = Array.isArray(node.content) ? node.content : []; + for (const child of children) collectTextNodes(child, acc); + return acc; +} diff --git a/tests/renderers.test.js b/tests/renderers.test.js new file mode 100644 index 0000000..3c1d66b --- /dev/null +++ b/tests/renderers.test.js @@ -0,0 +1,167 @@ +// Golden-ish tests for services/renderers/{phone,av}StatusRenderer.js +// +// True byte-for-byte golden strings aren't practical because both +// renderers call `simpleTimeAgo` (which uses `Date.now()`). Instead we +// assert on structural properties AND on the exact assembly of key +// lines — enough to catch a regression like "we lost the Meraki +// `[Meraki↗](url)` link" or "the header format changed" but robust to +// time-of-day drift. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js'; +import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js'; + +// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo` +// deterministic to "3 hours ago" for the duration of this test run. +const threeHrAgo = () => new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + +test('phone renderer: header and empty-store message', () => { + const md = renderPhoneStatusMarkdown({}, { storeNum: '782' }); + assert.match(md, /^\*\*Phone Status - Store 782\*\*/); + assert.match(md, /No phones or DECT basestations found/); +}); + +test('phone renderer: full desk-phone entry preserves Meraki link', () => { + const data = { + phones: { + data: [{ + displayName: 'PHONE 782-1', + status: 'connected', + lastSeen: threeHrAgo(), + firmware: '12.0.4', + serial: 'ABC12345', + meraki: { + switchName: 'STORE-782-SW1', + status: 'Online', + port: '17', + vlan: '20', + ip: '10.1.1.5', + lastSeen: threeHrAgo(), + clientUrl: 'https://n123.meraki.com/example/manage/clients/abc/overview', + usage: { sent: 1024, recv: 4096 }, + }, + }], + }, + telephonyProfile: { timeZone: 'America/New_York' }, + locationMainNumber: '+14125550100', + }; + const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false }); + // Header lines the poller cares about + assert.match(md, /\*\*Timezone:\*\* America\/New_York/); + assert.match(md, /\*\*PhoneNumber:\*\* \+14125550100/); + // Bold device name + assert.match(md, /\*\*PHONE 782-1\*\*/); + // Firmware / serial line + assert.match(md, /FW: 12\.0\.4 • Serial: ABC12345/); + // Meraki port info + link — the whole reason we ripped out the codeBlock + assert.match(md, /\*\*STORE-782-SW1\*\*/); + assert.match(md, /\[Meraki↗\]\(https:\/\/n123\.meraki\.com\/example\/manage\/clients\/abc\/overview\)/); + // Data usage + assert.match(md, /Data \(recent\): 1 KB sent \/ 4 KB recv/); + // No footer when opts.footer is false — Jira uses this mode + assert.doesNotMatch(md, /Last checked/); +}); + +test('phone renderer: footer appears when opts.footer=true (default) on non-empty data', () => { + // The empty-store branch legitimately returns early with no footer + // — that matches the chat handler's historical behavior. Feed a + // minimal populated fixture to exercise the footer path. + const data = { + phones: { + data: [{ displayName: 'X', status: 'connected', lastSeen: threeHrAgo() }], + }, + }; + const md = renderPhoneStatusMarkdown(data, { storeNum: '782' }); + assert.match(md, /Last checked:/); +}); + +test('phone renderer: detailed mode reveals SIP details', () => { + const data = { + phones: { + data: [{ + displayName: 'PHONE 782-2', + status: 'connected', + lastSeen: threeHrAgo(), + primarySipUrl: 'sip:782-2@aeo2go.webex.com', + sipUrls: ['sip:a@x', 'sip:b@x', 'sip:c@x'], + }], + }, + }; + const compact = renderPhoneStatusMarkdown(data, { storeNum: '782', detailed: false, footer: false }); + const detailed = renderPhoneStatusMarkdown(data, { storeNum: '782', detailed: true, footer: false }); + assert.doesNotMatch(compact, /SIP: sip:782-2/); + assert.match(detailed, /SIP: sip:782-2@aeo2go\.webex\.com/); + assert.match(detailed, /Alt SIPs: sip:a@x, sip:b@x…/); +}); + +test('av renderer: header always says (Mode: detailed)', () => { + const md = renderAvStatusMarkdown({}, { storeNum: '782', footer: false }); + assert.match(md, /^\*\*Device Status - Store 782\*\* \(Mode: detailed\)/); +}); + +test('av renderer: MDM entry with Meraki wired client preserves link', () => { + const data = { + mdm: { + data: [{ + friendlyName: 'STORE-782-KIOSK-1', + lastSeen: threeHrAgo(), + meraki: { + deviceName: 'STORE-782-SW2', + recentDeviceConnection: 'Wired', + status: 'Online', + clientStatus: 'Online', + port: '4', + vlan: '30', + ip: '10.1.2.20', + mac: 'aa:bb:cc:dd:ee:ff', + lastSeen: threeHrAgo(), + clientUrl: 'https://n123.meraki.com/example/manage/clients/xyz/overview', + }, + }], + }, + }; + const md = renderAvStatusMarkdown(data, { storeNum: '782', footer: false }); + assert.match(md, /\*\*STORE-782-KIOSK-1\*\*/); + assert.match(md, /\*\*STORE-782-SW2 \(Wired - Online\)\*\*/); + assert.match(md, /\[Meraki↗\]\(https:\/\/n123\.meraki\.com\/example\/manage\/clients\/xyz\/overview\)/); + // Port line with double-arrow indent + assert.match(md, / → → Port: \*\*4\*\*/); +}); + +test('av renderer: absent MDM devices prints friendly message', () => { + const md = renderAvStatusMarkdown({ mdm: { data: [] } }, { storeNum: '782', footer: false }); + assert.match(md, /No MDM devices found for this store/); +}); + +test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => { + const data = { + mdm: { data: [] }, + atlas: { + data: [{ + name: 'US000782AMP', + status: 'online', + last_seen_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + model: { name: 'Atlas-4M' }, + firmware: { version: '1.9.3' }, + sn: 'SN-782-AMP', + state: { + voltageMonitor: '120.4', + faultStatus: 0, + tempCpu: '40', tempPsu: '35', tempIo: '38', + fanSpeed: 45.7, + ampStatus_1: 'Active', + ampStatus_2: 'Ready', + IpAddress: '10.1.9.9', + }, + }], + }, + }; + const md = renderAvStatusMarkdown(data, { storeNum: '782', footer: false }); + assert.match(md, /\*\*Atlas Devices:\*\*/); + assert.match(md, /\*\*US000782AMP\*\* \(online\)/); + assert.match(md, /Atlas-4M • FW 1\.9\.3 • SN SN-782-AMP • IP 10\.1\.9\.9/); + assert.match(md, /CPU: 104°F • PSU: 95°F • Io: 100°F • Voltage: 120\.4V • Fan: 46%/); + assert.match(md, /Amps: Amp1: Active, Amp2: Ready/); +}); diff --git a/utils/adfComment.js b/utils/adfComment.js new file mode 100644 index 0000000..fd2bed9 --- /dev/null +++ b/utils/adfComment.js @@ -0,0 +1,33 @@ +// src/utils/adfComment.js +// +// Pure ADF comment assembly. Extracted so the poller integration test +// can import it without dragging in JiraClient / BotClient / Webex +// service-app auth (all of which throw on module load when their env +// vars aren't set). Also just cleaner: this function is a stateless +// shape helper, not part of the poller's lifecycle. + +/** + * Build a Jira Cloud v3 ADF document with an italic header paragraph + * followed by an arbitrary array of ADF body nodes (typically produced + * by `markdownToAdfContent`). Preserves clickable links, bold, and + * paragraph structure that the previous code-block format flattened + * to unformatted text. + * + * @param {object} args + * @param {string} args.headerLine Italic first-paragraph string. + * @param {Array} [args.bodyNodes=[]] ADF block nodes. + * @returns {object} ADF `{ version:1, type:'doc', content:[...] }`. + */ +export function buildAdfComment({ headerLine, bodyNodes = [] }) { + return { + version: 1, + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: headerLine, marks: [{ type: 'em' }] }], + }, + ...bodyNodes, + ], + }; +} diff --git a/utils/adfToPlainText.js b/utils/adfToPlainText.js new file mode 100644 index 0000000..2bf5273 --- /dev/null +++ b/utils/adfToPlainText.js @@ -0,0 +1,35 @@ +// src/utils/adfToPlainText.js +export function adfToPlainText(node) { + if (!node || typeof node !== 'object') return ''; + + let text = ''; + + // Text node + if (node.type === 'text' && node.text) { + text += node.text; + if (node.marks && Array.isArray(node.marks)) { + node.marks.forEach(mark => { + if (mark.type === 'link' && mark.attrs?.href) { + text += ` (${mark.attrs.href})`; + } + }); + } + } + + // Recurse into content + if (node.content && Array.isArray(node.content)) { + node.content.forEach((child, index) => { + const childText = adfToPlainText(child); + if (childText) { + text += childText; + if (['paragraph', 'heading', 'bulletList', 'orderedList', 'listItem'].includes(child.type)) { + text += '\n\n'; + } else if (index < node.content.length - 1) { + text += ' '; + } + } + }); + } + + return text.trim(); +} \ No newline at end of file diff --git a/utils/grokClient.js b/utils/grokClient.js new file mode 100644 index 0000000..53457aa --- /dev/null +++ b/utils/grokClient.js @@ -0,0 +1,83 @@ +// src/utils/grokClient.js +import axios from 'axios'; +import { logger } from './logger.js'; + +const GROK_API_URL = process.env.XAI_URL; +const GROK_API_KEY = process.env.XAI_API_KEY; +const GROK_MODEL_DEFAULT = process.env.XAI_MODEL; + +// Default system prompt for generic summarization / analysis callers. +// A caller can override this via `options.system` (e.g. the ticket +// classifier ships its own JSON-mode instructions). +const DEFAULT_SYSTEM_PROMPT = + 'You are a concise, professional IT support analyst. Be factual and clear.'; + +/** + * Call Grok (xAI) with a prompt. + * + * Backward-compatible signature. Existing callers passing only + * `{ temperature, max_tokens }` still get a string back. + * + * @param {string} prompt User prompt (message content). + * @param {object} [options] + * @param {number} [options.temperature=0.3] + * @param {number} [options.max_tokens=400] + * @param {string} [options.system] Override for the system prompt. + * @param {string} [options.model] Override for XAI_MODEL (per-call). + * @param {object} [options.response_format] OpenAI-compat response + * format hint, e.g. `{ type: 'json_object' }` to force JSON output. + * @param {boolean} [options.includeUsage=false] When true, return + * `{ content, usage }` instead of just the content string. Preserves + * the string return for every existing caller that didn't opt in. + * @param {number} [options.timeout=15000] Per-request timeout in ms. + * + * @returns {Promise} + * String by default. Object with `content` + `usage` when + * `options.includeUsage` is true. `usage` follows the OpenAI shape + * `{ prompt_tokens, completion_tokens, total_tokens }` — mirror what + * xAI returns, undefined if the API omitted it. + */ +export async function callGrok(prompt, options = {}) { + if (!GROK_API_KEY) { + logger('grok:client', 'Missing XAI_API_KEY', 'error'); + throw new Error('Grok API key not configured'); + } + + const model = options.model || GROK_MODEL_DEFAULT; + const payload = { + model, + messages: [ + { role: 'system', content: options.system || DEFAULT_SYSTEM_PROMPT }, + { role: 'user', content: prompt }, + ], + temperature: options.temperature ?? 0.3, + max_tokens: options.max_tokens ?? 400, + top_p: 0.95, + }; + + if (options.response_format) { + // OpenAI-compatible JSON mode. xAI supports this on Grok-3+; older + // model choices silently ignore it (they'll still return text but + // often wrapped in ```json fences that the caller has to strip). + payload.response_format = options.response_format; + } + + try { + const response = await axios.post(GROK_API_URL, payload, { + headers: { + Authorization: `Bearer ${GROK_API_KEY}`, + 'Content-Type': 'application/json', + }, + timeout: options.timeout ?? 15000, + }); + + const content = response.data.choices?.[0]?.message?.content?.trim(); + if (options.includeUsage) { + return { content, usage: response.data.usage }; + } + return content; + } catch (err) { + logger('grok:client', `API error: ${err.response?.data || err.message}`, 'error'); + throw err; + } +} diff --git a/utils/grokSummarizer.js b/utils/grokSummarizer.js new file mode 100644 index 0000000..61b5515 --- /dev/null +++ b/utils/grokSummarizer.js @@ -0,0 +1,200 @@ +// src/utils/grokSummarizer.js +import axios from 'axios'; +import { logger } from './logger.js'; +import { getWorkOrderNotes } from '../integrations/serviceChannel/client.js'; + +/** + * Summarize a batch of work orders using Grok + */ +export async function summarizeWorkOrders(workOrders) { + if (workOrders.length === 0) return []; + + logger('grok:summarizer', `Starting batch summarization for ${workOrders.length} work orders`); + + const batchSize = 8; // Keep small to avoid token limits + const batches = []; + + for (let i = 0; i < workOrders.length; i += batchSize) { + batches.push(workOrders.slice(i, i + batchSize)); + } + + const allSummaries = []; + + for (const batch of batches) { + logger('grok:summarizer', `Processing batch of ${batch.length} work orders`); + + try { + // Fetch notes for this batch in parallel + const batchWithNotes = await Promise.all( + batch.map(async (wo) => { + const notes = await getWorkOrderNotes(wo.id).catch((err) => { + logger('grok:summarizer', `Failed to fetch notes for WO ${wo.id}: ${err.message}`, 'warn'); + return []; + }); + return { ...wo, notes }; + }) + ); + + const prompt = ` +You are a concise technical summarizer for Service Channel work orders (Audio Visual trade). +For each work order below, create a **short but descriptive summary** (2-3 sentences max) focused ONLY on: +- What the problem was (from description) +- What troubleshooting and resolution steps were taken (prioritize notes/comments for specific actions) + +Output **ONLY** a valid JSON array, no extra text: +[ + { + "woNumber": "string", + "summary": "2-3 sentence summary here", + "status": "string", + "openedDate": "string", + "totalInvoiceCost": number + } +] + +Work orders with notes: +${JSON.stringify(batchWithNotes, null, 2)} +`; + + const response = await axios.post( + process.env.XAI_URL, + { + model: process.env.XAI_MODEL, + messages: [ + { role: 'system', content: 'Output ONLY valid JSON array. No other text.' }, + { role: 'user', content: prompt } + ], + temperature: 0.3, + max_tokens: 2000 + }, + { + headers: { + Authorization: `Bearer ${process.env.XAI_API_KEY}`, + 'Content-Type': 'application/json' + } + } + ); + + const content = response.data.choices[0].message.content.trim(); + let summaries = []; + + try { + summaries = JSON.parse(content); + } catch (parseErr) { + logger('grok:summarizer', `JSON parse failed for batch. Raw content: ${content.substring(0, 300)}...`, 'warn'); + summaries = batchWithNotes.map(wo => ({ + woNumber: wo.woNumber, + summary: wo.summary || 'No summary available', + status: wo.status, + openedDate: wo.openedDate, + totalInvoiceCost: wo.totalInvoiceCost + })); + } + + allSummaries.push(...summaries); + logger('grok:summarizer', `Successfully summarized batch of ${batch.length} work orders`); + + } catch (err) { + logger('grok:summarizer', `Batch summarization failed: ${err.message}`, 'error'); + // Fallback: return basic info + allSummaries.push(...batch.map(wo => ({ + woNumber: wo.woNumber, + summary: wo.summary || 'Summary unavailable due to error', + status: wo.status, + openedDate: wo.openedDate, + totalInvoiceCost: wo.totalInvoiceCost + }))); + } + } + + logger('grok:summarizer', `Completed summarization of ${allSummaries.length} work orders`); + return allSummaries; +} + +/** + * Generate a Grok summary from a single ticket + notes + */ +export async function summarizeTicketWithGrok(ticket, notes = []) { + logger('grok:summarizer', `Summarizing ticket ${ticket.Id || 'N/A'}`); + + try { + let contextText = `Ticket ID: ${ticket.Id || 'N/A'}\n`; + contextText += `Title/Description: ${ticket.Description || 'N/A'}\n`; + contextText += `Trade: ${ticket.Trade || 'N/A'}\n`; + + let statusValue = 'N/A'; + if (ticket.Status) { + if (typeof ticket.Status === 'string') statusValue = ticket.Status; + else if (typeof ticket.Status === 'object') { + statusValue = ticket.Status.Primary || ticket.Status.Extended || 'N/A'; + } + } + contextText += `Status: ${statusValue}\n`; + contextText += `Priority: ${ticket.Priority || 'N/A'}\n`; + contextText += `Location/Store: ${ticket.Location?.StoreId || 'N/A'}\n`; + contextText += `Not-to-Exceed (NTE): $${ticket.Nte || 'N/A'}\n\n`; + + contextText += `Notes / Updates (chronological, most recent last):\n\n`; + notes.forEach(note => { + const date = new Date(note.date || '').toLocaleString('en-US', { + timeZone: 'America/New_York', + dateStyle: 'short', + timeStyle: 'short' + }); + const author = note.createdBy || 'Unknown'; + const type = note.NoteType || note.Type || 'Note'; + contextText += `[${date}] ${author} (${type}):\n`; + contextText += `${(note.text || '').trim()}\n`; + contextText += '---\n'; + }); + + const prompt = ` +You are an expert HVAC/facilities technician and ServiceChannel ticket analyst. +Summarize this ticket clearly and concisely. + +Use the **ticket description** as the primary source for the **main problem**. +Use the notes to provide timeline, actions, status updates, and pending items. + +Structure your summary with these sections: +- **Main Problem** (from description) +- **Key Events & Timeline** (from notes) +- **Actions Taken** +- **Current Status / Blockers** +- **Pending / Next Steps** + +Keep it professional, neutral, factual, under 250 words. +Use bullet points where helpful. + +Ticket data & notes: +${contextText} +`; + + const response = await axios.post( + process.env.XAI_URL, + { + model: process.env.XAI_MODEL, + messages: [ + { role: 'system', content: 'You are a concise, technical ticket analyst.' }, + { role: 'user', content: prompt } + ], + temperature: 0.3, + max_tokens: 500, + top_p: 0.95 + }, + { + headers: { + Authorization: `Bearer ${process.env.XAI_API_KEY}`, + 'Content-Type': 'application/json' + } + } + ); + + const summary = response.data.choices[0].message.content.trim(); + logger('grok:summarizer', `Successfully summarized ticket ${ticket.Id || 'N/A'}`); + return summary; + + } catch (error) { + logger('grok:summarizer', `Grok summarization error for ticket ${ticket.Id || 'N/A'}: ${error.message}`, 'error'); + return 'Summary unavailable due to error.'; + } +} \ No newline at end of file diff --git a/utils/httpAuth.js b/utils/httpAuth.js new file mode 100644 index 0000000..e59fcee --- /dev/null +++ b/utils/httpAuth.js @@ -0,0 +1,115 @@ +// src/utils/httpAuth.js +// +// Shared-secret auth for HTTP API endpoints (used by index.js). +// +// Why this exists +// --------------- +// The generic /:command HTTP router dispatches to the same handlers used by +// the Webex bot, including destructive ones (offboardUser, provision-*, +// vcMonitor, bulkAvSwitchCSV, …). Without auth, anyone with network access to +// the bot's port can trigger them. +// +// Behavior +// -------- +// - The token is taken from process.env.HTTP_API_TOKEN (read at request time +// so it stays in sync with hot-reloaded .env in dev). +// - Accepted on either header: `Authorization: Bearer ` or +// `X-API-Token: `. The query string is intentionally NOT supported +// to avoid leaking the token into proxy/access logs. +// - If HTTP_API_TOKEN is not configured the middleware fails closed with 503 +// so destructive endpoints are *never* accidentally exposed when the +// operator forgot to set the variable. +// - Comparison uses crypto.timingSafeEqual to avoid timing oracles. + +import crypto from 'node:crypto'; +import { logger } from './logger.js'; + +function extractToken(req) { + const auth = req.headers['authorization']; + if (typeof auth === 'string') { + const match = auth.match(/^Bearer\s+(.+)$/i); + if (match) return match[1].trim(); + } + const headerToken = req.headers['x-api-token']; + if (typeof headerToken === 'string' && headerToken.trim()) { + return headerToken.trim(); + } + return null; +} + +function constantTimeEquals(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const aBuf = Buffer.from(a, 'utf8'); + const bBuf = Buffer.from(b, 'utf8'); + // Pad to equal length so timingSafeEqual doesn't throw on length mismatch + // (and so the length difference itself isn't a side channel). + if (aBuf.length !== bBuf.length) { + const max = Math.max(aBuf.length, bBuf.length); + const aPad = Buffer.alloc(max); + const bPad = Buffer.alloc(max); + aBuf.copy(aPad); + bBuf.copy(bPad); + crypto.timingSafeEqual(aPad, bPad); // burn cycles for shape consistency + return false; + } + return crypto.timingSafeEqual(aBuf, bBuf); +} + +/** + * Returns an Express middleware that requires a valid API token on the request. + * + * @param {object} [opts] + * @param {string} [opts.scope='http'] - Tag used in log lines for context. + */ +export function requireApiToken({ scope = 'http' } = {}) { + return function (req, res, next) { + const expected = process.env.HTTP_API_TOKEN; + + if (!expected) { + logger( + `auth:${scope}`, + `Refusing ${req.method} ${req.originalUrl} — HTTP_API_TOKEN is not configured (fail-closed)`, + 'error' + ); + return res.status(503).json({ + error: 'HTTP_API_TOKEN not configured on server', + hint: 'Set HTTP_API_TOKEN in the environment to enable authenticated HTTP API access.', + }); + } + + const provided = extractToken(req); + if (!provided) { + logger( + `auth:${scope}`, + `Unauthorized ${req.method} ${req.originalUrl} — missing token`, + 'warn' + ); + return res + .status(401) + .set('WWW-Authenticate', 'Bearer realm="collabfinder"') + .json({ error: 'Missing API token (Authorization: Bearer or X-API-Token header)' }); + } + + if (!constantTimeEquals(provided, expected)) { + logger( + `auth:${scope}`, + `Forbidden ${req.method} ${req.originalUrl} — invalid token`, + 'warn' + ); + return res.status(403).json({ error: 'Invalid API token' }); + } + + return next(); + }; +} + +/** + * Convenience: returns true when HTTP_API_REQUIRE_AUTH is set to a truthy value, + * meaning every /:command (not just mutating ones) should require a token. + */ +export function requireAuthForAllCommands() { + const v = (process.env.HTTP_API_REQUIRE_AUTH || '').toLowerCase().trim(); + return v === '1' || v === 'true' || v === 'yes'; +} + +export default requireApiToken; diff --git a/utils/logger.js b/utils/logger.js new file mode 100644 index 0000000..2b35db0 --- /dev/null +++ b/utils/logger.js @@ -0,0 +1,127 @@ +// utils/logger.js +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const projectRoot = path.resolve(__dirname, '..'); +const LOG_DIR = path.join(projectRoot, 'logs'); + +// Ensure logs directory exists +if (!fs.existsSync(LOG_DIR)) { + fs.mkdirSync(LOG_DIR, { recursive: true }); +} + +const getLogFileName = () => { + const date = new Date().toISOString().split('T')[0]; + return path.join(LOG_DIR, `${date}.log`); +}; + +// Cleanup logs older than 14 days +const cleanOldLogs = () => { + try { + const files = fs.readdirSync(LOG_DIR); + const now = Date.now(); + const fourteenDaysAgo = now - (14 * 24 * 60 * 60 * 1000); + + files.forEach(file => { + if (!file.endsWith('.log')) return; + const filePath = path.join(LOG_DIR, file); + const stats = fs.statSync(filePath); + if (stats.mtimeMs < fourteenDaysAgo) { + fs.unlinkSync(filePath); + } + }); + } catch (err) { + console.error(`[LOGGER] Failed to clean old logs: ${err.message}`); + } +}; + +// Run cleanup on startup +cleanOldLogs(); + +const levels = { + info: 'INFO ', + warn: 'WARN ', + error: 'ERROR', + debug: 'DEBUG' +}; + +/** + * Safe string conversion for any value + */ +function safeString(value) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (e) { + return `[Object ${Object.prototype.toString.call(value)}]`; + } + } + return String(value); +} + +export function logger(module, message, level = 'info') { + const effectiveLevel = (process.env.LOG_LEVEL || 'info').toLowerCase(); + const levelOrder = { debug: 10, info: 20, warn: 30, error: 40 }; + if ((levelOrder[level] || 20) < (levelOrder[effectiveLevel] || 20)) { + return; // filtered + } + + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + const millis = String(now.getMilliseconds()).padStart(3, '0'); + + const timestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${millis}`; + const levelStr = levels[level] || 'INFO '; + + // Ultra-safe message conversion + let messageStr = ''; + if (message == null) { + messageStr = message === null ? 'null' : 'undefined'; + } else if (typeof message === 'object') { + try { + messageStr = JSON.stringify(message, null, 2); + } catch (e) { + messageStr = `[Object]`; + } + } else { + messageStr = String(message); + } + + const logLine = `[${timestamp}] [${levelStr}] [${module}] ${messageStr}\n`; + + // Console output + if (level === 'error') { + console.error(logLine.trim()); + } else if (level === 'warn') { + console.warn(logLine.trim()); + } else { + console.log(logLine.trim()); + } + + // Write to daily log file + try { + const logFile = getLogFileName(); + fs.appendFileSync(logFile, logLine, 'utf8'); + } catch (err) { + console.error(`[LOGGER] Failed to write to log file: ${err.message}`); + } +} + +// Convenience methods +export const logInfo = (module, msg) => logger(module, msg, 'info'); +export const logWarn = (module, msg) => logger(module, msg, 'warn'); +export const logError = (module, msg) => logger(module, msg, 'error'); +export const logDebug = (module, msg) => logger(module, msg, 'debug'); + +export default logger; \ No newline at end of file diff --git a/utils/markdownToAdf.js b/utils/markdownToAdf.js new file mode 100644 index 0000000..004d0f7 --- /dev/null +++ b/utils/markdownToAdf.js @@ -0,0 +1,169 @@ +// src/utils/markdownToAdf.js +// +// Narrow, hand-rolled markdown → Atlassian Document Format converter. +// +// Grammar supported (deliberately minimal, matches what our chat +// renderers actually emit — see commands/phoneStatus.js and +// commands/avStatus.js): +// +// **text** → { type:'text', text, marks:[{type:'strong'}] } +// *text* → { type:'text', text, marks:[{type:'em'}] } +// [label](url) → { type:'text', text:label, marks:[{type:'link',attrs:{href:url}}] } +// \n\n (blank line) → paragraph split +// \n (single line) → { type:'hardBreak' } inside the paragraph +// anything else → plain { type:'text', text } — emojis, arrows, +// bullets, unicode symbols all pass through. +// +// Explicitly NOT supported: headings, bulletLists, fenced code, tables, +// nested marks, block quotes, images. Our renderers don't produce any +// of that; if they start to, we'll extend the grammar then rather than +// pull in a full CommonMark library for a narrow use case. +// +// The parser is deliberately forgiving with malformed input. An unclosed +// `**` becomes plain text — never an exception. That's the right call +// for a converter that runs unattended on live ticket data. + +const LINK_RE = /\[([^\]\n]+?)\]\(([^)\n]+?)\)/; +const STRONG_RE = /\*\*([^*\n][^*\n]*?)\*\*/; +const EM_RE = /\*([^*\n]+?)\*/; + +// Attempt each pattern at the beginning of the remaining slice and +// return the earliest match. Ties resolve by pattern order (link, +// strong, em) — which happens to be the safe direction (link brackets +// can't be confused with `*`, and `**` must be tried before `*`). +function findEarliestMatch(text) { + const candidates = [ + { re: LINK_RE, kind: 'link' }, + { re: STRONG_RE, kind: 'strong' }, + { re: EM_RE, kind: 'em' }, + ]; + let best = null; + for (const c of candidates) { + const m = c.re.exec(text); + if (m && (best === null || m.index < best.match.index)) { + best = { kind: c.kind, match: m }; + // Can't early-exit — a link at index 5 beats a strong at index 0 + // is impossible (best keeps track), but a link at index 5 CAN + // beat a strong at index 10. + } + } + return best; +} + +/** + * Tokenize a single line (no `\n` in the input) into a sequence of + * ADF inline nodes. Returns [] for empty input. + */ +export function tokenizeLine(line) { + if (!line) return []; + const nodes = []; + let cursor = 0; + while (cursor < line.length) { + const rest = line.slice(cursor); + const found = findEarliestMatch(rest); + if (!found) { + // No more markup — the rest is plain text. + pushText(nodes, line.slice(cursor)); + break; + } + // Emit whatever plain text sits before the match. + if (found.match.index > 0) { + pushText(nodes, rest.slice(0, found.match.index)); + } + // Emit the marked node. + if (found.kind === 'link') { + const [, label, url] = found.match; + nodes.push({ + type: 'text', + text: label, + marks: [{ type: 'link', attrs: { href: url } }], + }); + } else { + // strong or em — both extract group 1 as inner text. + const inner = found.match[1]; + nodes.push({ + type: 'text', + text: inner, + marks: [{ type: found.kind }], + }); + } + cursor += found.match.index + found.match[0].length; + } + return nodes; +} + +// Append a text node while collapsing adjacent runs of plain text. +// ADF permits multiple sibling text nodes, but a single collapsed node +// keeps the output tidy for humans skimming a rendered ADF payload. +function pushText(nodes, text) { + if (!text) return; + const last = nodes[nodes.length - 1]; + if (last && last.type === 'text' && !last.marks) { + last.text += text; + } else { + nodes.push({ type: 'text', text }); + } +} + +/** + * Convert a markdown string to an array of ADF block nodes (paragraphs + * containing inline nodes and hardBreaks). Suitable for splicing into + * a larger ADF document's `content` array — e.g. below a fixed header + * paragraph in the Jira poller's `buildAdfComment`. + * + * Blank lines separate paragraphs. Non-blank lines within a paragraph + * are joined with `hardBreak` nodes so line-oriented output like the + * chat renderers survives visually intact. + * + * @param {string} markdown + * @returns {Array} ADF content nodes (each a `paragraph`). + */ +export function markdownToAdfContent(markdown) { + if (typeof markdown !== 'string' || markdown.trim() === '') return []; + + // Split into paragraph groups on blank-line boundaries. Preserve + // relative order — leading/trailing blank lines just yield empty + // groups that we drop. + const groups = markdown + .split(/\n\s*\n/) + .map((g) => g.replace(/\n+$/, '')) + .filter((g) => g.length > 0); + + const paragraphs = []; + for (const group of groups) { + const lines = group.split('\n'); + const content = []; + lines.forEach((line, i) => { + const inline = tokenizeLine(line); + if (inline.length > 0) content.push(...inline); + if (i < lines.length - 1) { + // Soft line break inside a paragraph. ADF's hardBreak renders + // as an in-paragraph line break in the Jira viewer. + content.push({ type: 'hardBreak' }); + } + }); + // Skip paragraphs that ended up empty (e.g. a group that was just + // whitespace lines). An empty ADF paragraph is legal but noisy. + if (content.length === 0) continue; + paragraphs.push({ type: 'paragraph', content }); + } + + return paragraphs; +} + +/** + * Full-document convenience: wrap `markdownToAdfContent` in a valid + * ADF `doc`. Callers that want to embed the paragraphs inside a + * larger custom document (like the poller's header + body pattern) + * should call `markdownToAdfContent` directly. + * + * @param {string} markdown + * @returns {object} ADF document `{ version:1, type:'doc', content }`. + */ +export function markdownToAdf(markdown) { + return { + version: 1, + type: 'doc', + content: markdownToAdfContent(markdown), + }; +} diff --git a/utils/normalize.js b/utils/normalize.js new file mode 100644 index 0000000..a1d5809 --- /dev/null +++ b/utils/normalize.js @@ -0,0 +1,23 @@ +// src/utils/normalize.js +export function normalizePlayerName(name) { + if (typeof name !== 'string' || !name.trim()) return ''; + const upper = name.trim().toUpperCase(); + + // Your original logic (country.store.suffix patterns) + const match = upper.match(/^([A-Z]{2})\.?(?:(\d{1,6})\.?)?(?:OFFLINE|AE|AERIE)?\.?(\d{1,6})?\.?(OFFLINE|AE|AERIE)?$/i); + if (match) { + const country = match[1]; + let store = match[2] || match[3] || ''; + const suffixRaw = match[4] || ''; + if (!store) return name.trim(); + + const paddedStore = store.padStart(6, '0'); + let suffix = ''; + if (suffixRaw === 'OFFLINE') suffix = 'MSCOFF'; + else if (suffixRaw === 'AE' || suffixRaw === 'AERIE') suffix = `MSC${suffixRaw}`; + + return `${country}${paddedStore}${suffix}`; + } + + return name.trim(); +} \ No newline at end of file diff --git a/utils/pendingHostAssigns.js b/utils/pendingHostAssigns.js new file mode 100644 index 0000000..d68465f --- /dev/null +++ b/utils/pendingHostAssigns.js @@ -0,0 +1,53 @@ +// src/utils/pendingHostAssigns.js +// +// In-memory store for pending Webex host-license assignment confirmation +// cards. Identical shape to pendingOffboards — every entry is stamped with a +// `timestamp` on insert, and a background sweep expires un-acted cards after +// TTL_MS so the map never leaks. + +import { logger } from './logger.js'; + +const TTL_MS = 10 * 60 * 1000; // expire un-acted cards after 10 min +const SWEEP_INTERVAL_MS = 60 * 1000; // check once a minute + +const _store = new Map(); + +export const pendingHostAssigns = { + set(cardId, data) { + _store.set(cardId, { ...data, timestamp: Date.now() }); + return this; + }, + + get(cardId) { + return _store.get(cardId); + }, + + has(cardId) { + return _store.has(cardId); + }, + + delete(cardId) { + return _store.delete(cardId); + }, + + get size() { + return _store.size; + }, + + entries() { + return _store.entries(); + }, +}; + +const sweepHandle = setInterval(() => { + const now = Date.now(); + for (const [cardId, data] of _store.entries()) { + const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0; + if (now - stamped > TTL_MS) { + logger('webexhost:cleanup', `Expired card ${cardId} for ${data?.email || 'unknown'}`); + _store.delete(cardId); + } + } +}, SWEEP_INTERVAL_MS); + +sweepHandle.unref?.(); diff --git a/utils/pendingOffboards.js b/utils/pendingOffboards.js new file mode 100644 index 0000000..4e9ab05 --- /dev/null +++ b/utils/pendingOffboards.js @@ -0,0 +1,57 @@ +// src/utils/pendingOffboards.js +// +// In-memory store for pending offboard confirmation cards. +// +// We wrap a plain Map so every entry is stamped with a `timestamp` on insert +// (callers don't have to remember to set one). A background sweep expires +// entries older than TTL_MS to prevent the map from leaking when users +// generate offboard cards but never click confirm/cancel. + +import { logger } from './logger.js'; + +const TTL_MS = 10 * 60 * 1000; // expire un-acted cards after 10 min +const SWEEP_INTERVAL_MS = 60 * 1000; // check once a minute + +const _store = new Map(); + +export const pendingOffboards = { + set(cardId, data) { + _store.set(cardId, { ...data, timestamp: Date.now() }); + return this; + }, + + get(cardId) { + return _store.get(cardId); + }, + + has(cardId) { + return _store.has(cardId); + }, + + delete(cardId) { + return _store.delete(cardId); + }, + + get size() { + return _store.size; + }, + + entries() { + return _store.entries(); + }, +}; + +const sweepHandle = setInterval(() => { + const now = Date.now(); + for (const [cardId, data] of _store.entries()) { + const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0; + if (now - stamped > TTL_MS) { + logger('offboard:cleanup', `Expired card ${cardId} for ${data?.email || 'unknown'}`); + _store.delete(cardId); + } + } +}, SWEEP_INTERVAL_MS); + +// Don't keep the event loop alive just for this sweep (lets the process exit +// cleanly during tests / SIGINT without an explicit clearInterval call). +sweepHandle.unref?.(); diff --git a/utils/requester.js b/utils/requester.js new file mode 100644 index 0000000..b277f5d --- /dev/null +++ b/utils/requester.js @@ -0,0 +1,56 @@ +// utils/requester.js +// +// Single source of truth for "who initiated this bot action?" — used for +// audit log lines, attribution in confirmation messages, and the requester +// snapshot stored alongside pending adaptive cards. +// +// IMPORTANT — why this file exists: +// webex-node-bot-framework does NOT expose `trigger.personEmail` or +// `trigger.personDisplayName` as top-level fields, despite a couple of +// stale references in its own JSDoc examples. The framework always +// populates `trigger.person` (the full Webex Person object, fetched via +// `webex.people.get(triggerObject.personId)` — see framework.js around +// line 697) for both `message` and `attachmentAction` triggers. The raw +// Webex `attachmentAction` payload only carries `personId`, not the +// email. So the only reliable way to attribute the requester is via +// `trigger.person.emails[0]` and `trigger.person.displayName`. +// +// HTTP-originated triggers (built in index.js's `app.get('/:command', ...)` +// adapter) carry `source: 'http'` and no `person` field — that case yields +// `email/displayName = null` and `describeRequester` renders it as +// "via HTTP API" for the audit log. + +/** + * Build a structured requester record from a trigger. + * + * @param {object} trigger - webex-node-bot-framework trigger or HTTP fake + * trigger (from index.js). + * @returns {{email: string|null, displayName: string|null, source: 'webex'|'http'}} + */ +export function extractRequester(trigger) { + const isHttp = trigger?.source === 'http'; + const rawEmail = trigger?.person?.emails?.[0]; + return { + email: rawEmail ? String(rawEmail).toLowerCase() : null, + displayName: trigger?.person?.displayName || null, + source: isHttp ? 'http' : 'webex', + }; +} + +/** + * Render a requester as a human-readable string for audit log lines. + * + * Examples: + * "chat user benhumeag@ae.com" + * "via HTTP API" + * "unknown requester" + * + * @param {ReturnType | null | undefined} requester + * @returns {string} + */ +export function describeRequester(requester) { + if (!requester) return 'unknown requester'; + if (requester.source === 'http') return 'via HTTP API'; + const who = requester.email || requester.displayName || 'unknown chat user'; + return `chat user ${who}`; +} diff --git a/utils/time.js b/utils/time.js new file mode 100644 index 0000000..b8ff422 --- /dev/null +++ b/utils/time.js @@ -0,0 +1,91 @@ +// utils/time.js +import { logger } from './logger.js'; + +/** + * Human-readable "X time ago" from ISO string or Date + * @param {string|Date|number} input - ISO string, Date object, or timestamp + * @returns {string} e.g. "2 hours ago", "just now", "3 days ago" + */ +export function simpleTimeAgo(input) { + if (!input) return 'never'; + + let date; + + try { + if (input instanceof Date) { + date = input; + } else if (typeof input === 'number') { + date = new Date(input); + } else if (typeof input === 'string') { + let cleaned = input.trim(); + + // If no timezone (no Z or offset), assume UTC and append Z + if (!cleaned.endsWith('Z') && !cleaned.match(/[+-]\d{2}:\d{2}$/)) { + cleaned += 'Z'; + } + + date = new Date(cleaned); + } else { + logger('time', `Invalid input type to simpleTimeAgo: ${typeof input}`, 'warn'); + return 'invalid'; + } + + if (isNaN(date.getTime())) { + logger('time', `Invalid date parsed from: ${input}`, 'warn'); + return 'invalid'; + } + + const now = Date.now(); + const diffMs = now - date.getTime(); + + // Small clock skew tolerance (< 5 minutes in the future → treat as "just now") + if (diffMs < 0 && diffMs > -300000) { + return 'just now'; + } + + // Future dates + if (diffMs < 0) { + const futureMs = -diffMs; + const futureSeconds = Math.floor(futureMs / 1000); + const futureMinutes = Math.floor(futureSeconds / 60); + const futureHours = Math.floor(futureMinutes / 60); + const futureDays = Math.floor(futureHours / 24); + + if (futureSeconds < 60) return `in ${futureSeconds} seconds`; + if (futureMinutes < 60) return `in ${futureMinutes} minutes`; + if (futureHours < 24) return `in ${futureHours} hours`; + return `in ${futureDays} days`; + } + + // Past dates + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return `${seconds} seconds ago`; + if (minutes < 60) return `${minutes} minutes ago`; + if (hours < 24) return `${hours} hours ago`; + if (days < 30) return `${days} days ago`; + + // Fallback for very old dates + return date.toLocaleDateString(); + + } catch (err) { + logger('time', `Error in simpleTimeAgo: ${err.message}`, 'warn'); + return 'invalid'; + } +} + +/** + * Format a byte count into a human-readable string (e.g. "172 KB"). + * Used for Meraki client usage in phone status (and reusable elsewhere). + */ +export function formatBytes(bytes, decimals = 1) { + if (bytes == null || bytes === 0) return '0 B'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} \ No newline at end of file