diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..423a677 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: /cloudflare-worker + schedule: + interval: weekly + groups: + worker-deps: + patterns: + - "*" + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2dc86cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install -r requirements-dev.txt + - run: ruff check . + - run: pytest -q + + worker: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cloudflare-worker + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: cloudflare-worker/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test diff --git a/.gitignore b/.gitignore index d3c46f1..4afceb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,14 @@ venv/ .env log.txt -.vscode \ No newline at end of file +.vscode +domains.txt +domain_status.csv +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ + +# Wrangler local cache / state (should never be committed) +.wrangler/ +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f706189 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ + +## Linear + +Linear-Projekt: **INWX Domain-Bot** — https://linear.app/sagorski/project/inwx-domain-bot-ac4f0cfa1e3a + +Zu Session-Beginn die offenen Issues dieses Projekts in Linear lesen (Tool `list_issues`, project = obige URL/ID). +Substanzielle Arbeit als Linear-Issue anlegen bzw. ein bestehendes aktualisieren; bei Abschluss das Issue schliessen. +Team: Kolja Sagorski (KOL). diff --git a/check_domain.py b/check_domain.py index e1f0a30..6d683c3 100644 --- a/check_domain.py +++ b/check_domain.py @@ -1,65 +1,90 @@ +import csv import logging import os -import csv +import sys +import time +from pathlib import Path + from dotenv import load_dotenv from INWX.Domrobot import ApiClient -# Setup logging -logging.basicConfig( - level=logging.INFO, - filename="log.txt", - format='%(asctime)s %(levelname)s:%(message)s') - -# Load environment variables load_dotenv() -# Initialize API client +LOG_FILE = os.getenv("log_file", "log.txt") +DOMAINS_FILE = os.getenv("domains_file", "domains.txt") +CSV_FILE = os.getenv("csv_file", "domain_status.csv") +API_DELAY_SECONDS = float(os.getenv("api_delay_seconds", "1.0")) + api_client = ApiClient(api_url=ApiClient.API_LIVE_URL, debug_mode=False) -# Utility function to log errors and raise exceptions -def log_and_raise_error(code, message, context=""): - error_message = f"API error {context}. Code: {code}, Message: {message}" - logging.error(error_message) - raise Exception(error_message) + +class InwxApiError(Exception): + def __init__(self, code, msg, context=""): + self.code = code + self.msg = msg + self.context = context + super().__init__(f"API error {context}. Code: {code}, Message: {msg}") + + +def setup_logging(): + root = logging.getLogger() + root.setLevel(logging.INFO) + for handler in list(root.handlers): + root.removeHandler(handler) + fmt = logging.Formatter("%(asctime)s %(levelname)s:%(message)s") + file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + file_handler.setFormatter(fmt) + root.addHandler(file_handler) + console = logging.StreamHandler(sys.stderr) + console.setLevel(logging.WARNING) + console.setFormatter(fmt) + root.addHandler(console) + + +def _call(action, params=None, context=""): + """Wrapper around api_client.call_api that enforces a successful response.""" + result = api_client.call_api(action, params or {}) + code = result.get("code") + if code == 1000: + return result + msg = result.get("msg", "") + logging.error("API error %s. Code: %s, Message: %s", context, code, msg) + raise InwxApiError(code, msg, context) + def login(username, password): - """Login to INWX""" - login_result = api_client.login(username, password) - if login_result['code'] == 1000: - logging.info("Login successful.") - return login_result - else: - log_and_raise_error(login_result['code'], login_result['msg'], "during login") + result = api_client.login(username, password) + if result.get("code") != 1000: + raise InwxApiError(result.get("code"), result.get("msg", ""), "during login") + logging.info("Login successful.") + return result + def is_domain_free(domain_name): - """Check if the domain is available""" - domain_check_result = api_client.call_api('domain.check', {'domain': domain_name}) - if domain_check_result['code'] == 1000: - checked_domain = domain_check_result['resData']['domain'][0] - return bool(checked_domain.get('avail', False)) - else: - log_and_raise_error(domain_check_result['code'], domain_check_result.get('msg', ''), "during domain check") + result = _call("domain.check", {"domain": domain_name}, "during domain check") + return bool(result["resData"]["domain"][0].get("avail", False)) + def get_account_info(): - """Get account information required to buy a domain""" - account_info_result = api_client.call_api('account.info') - if account_info_result['code'] == 1000: - logging.info("Account info retrieved successfully.") - return account_info_result['resData'] - else: - log_and_raise_error(account_info_result['code'], account_info_result.get('msg', ''), "while fetching account info") + result = _call("account.info", context="while fetching account info") + logging.info("Account info retrieved successfully.") + return result["resData"] + def buy_domain(buy_params): - """Buy a domain (returns (success, code, msg))""" - domain_buy_result = api_client.call_api('domain.create', buy_params) - code = domain_buy_result.get('code') - msg = domain_buy_result.get('msg', '') + """Buy a domain (returns (success, code, msg)).""" + result = api_client.call_api("domain.create", buy_params) + code = result.get("code") + msg = result.get("msg", "") if code == 1000: - logging.info(f"Domain {buy_params['domain']} purchased successfully.") + logging.info("Domain %s purchased successfully.", buy_params["domain"]) return True, code, msg - else: - logging.error(f"Failed to purchase domain {buy_params['domain']}. Code: {code}, Message: {msg}") - return False, code, msg + logging.error( + "Failed to purchase domain %s. Code: %s, Message: %s", + buy_params["domain"], code, msg, + ) + return False, code, msg + def print_live_status(idx, total, domain, status, detail=""): prefix = f"[{idx}/{total}] {domain:<40}" @@ -68,121 +93,141 @@ def print_live_status(idx, total, domain, status, detail=""): line += f" — {detail}" print(line) + +def load_domains(path): + p = Path(path) + if not p.is_file(): + raise FileNotFoundError(f"Domain list file not found: {path}") + with p.open(encoding="utf-8") as f: + return [d.strip() for d in f.read().splitlines() if d.strip()] + + +def write_csv(path, statuses): + try: + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter( + f, + fieldnames=["domain", "available", "action", "detail", "api_code", "api_msg"], + ) + writer.writeheader() + writer.writerows(statuses) + print(f"\nCSV gespeichert: {path}") + except OSError as e: + logging.error("Failed to write CSV: %s", e) + print(f"\nKonnte CSV nicht schreiben: {e}") + + +def process_domain(idx, total, domain, account_info, ns): + available = is_domain_free(domain) + if not available: + print_live_status(idx, total, domain, "NICHT VERFÜGBAR") + return { + "domain": domain, + "available": False, + "action": "skipped", + "detail": "already registered", + "api_code": 1000, + "api_msg": "domain not available", + } + + print_live_status(idx, total, domain, "VERFÜGBAR", "Kaufe…") + buy_params = { + "domain": domain, + "registrant": account_info["defaultRegistrant"], + "admin": account_info["defaultAdmin"], + "tech": account_info["defaultTech"], + "billing": account_info["defaultBilling"], + } + if ns: + buy_params["ns"] = ns + + success, code, msg = buy_domain(buy_params) + if success: + print_live_status(idx, total, domain, "GEKAUFT", "Command completed successfully") + return { + "domain": domain, + "available": True, + "action": "purchased", + "detail": "success", + "api_code": code, + "api_msg": msg, + } + print_live_status(idx, total, domain, "KAUF FEHLGESCHLAGEN", f"Code {code}: {msg}") + return { + "domain": domain, + "available": True, + "action": "purchase_failed", + "detail": f"Code {code}: {msg}", + "api_code": code, + "api_msg": msg, + } + + +def print_summary(statuses): + print("\nZusammenfassung:") + header = f"{'Domain':40} {'Avail':5} {'Action':16} {'Detail'}" + print(header) + print("-" * len(header)) + for s in statuses: + avail = {True: "yes", False: "no"}.get(s["available"], "-") + detail = s["detail"] or "" + print(f"{s['domain']:40} {avail:5} {s['action']:16} {detail}") + + def main(): - statuses = [] # collect status per domain for summary + CSV + setup_logging() + statuses = [] + logged_in = False try: - # Login - username = os.getenv('username') - password = os.getenv('password') + username = os.getenv("username") + password = os.getenv("password") if not username or not password: raise ValueError("Username or password not set in environment variables.") login(username, password) + logged_in = True - # Get account info account_info = get_account_info() + ns = [v for v in (os.getenv("ns1"), os.getenv("ns2")) if v] - # Nameserver (optional): only include if set & non-empty - ns_env = [os.getenv('ns1'), os.getenv('ns2')] - ns = [v for v in ns_env if v] # filter None/empty - - # Read domain list from file - with open("domains.txt", encoding='utf-8') as file: - domains = [d.strip() for d in file.read().splitlines() if d.strip()] - + domains = load_domains(DOMAINS_FILE) total = len(domains) if total == 0: - print("Keine Domains in domains.txt gefunden.") + print(f"Keine Domains in {DOMAINS_FILE} gefunden.") return print(f"Prüfe {total} Domains bei INWX …\n") - # Process domains for i, domain in enumerate(domains, start=1): + if i > 1 and API_DELAY_SECONDS > 0: + time.sleep(API_DELAY_SECONDS) try: - available = is_domain_free(domain) - if available: - print_live_status(i, total, domain, "VERFÜGBAR", "Kaufe…") - buy_params = { - 'domain': domain, - 'registrant': account_info['defaultRegistrant'], - 'admin': account_info['defaultAdmin'], - 'tech': account_info['defaultTech'], - 'billing': account_info['defaultBilling'], - } - if ns: - buy_params['ns'] = ns - - success, code, msg = buy_domain(buy_params) - if success: - print_live_status(i, total, domain, "GEKAUFT", "Command completed successfully") - statuses.append({ - 'domain': domain, - 'available': True, - 'action': 'purchased', - 'detail': 'success', - 'api_code': code, - 'api_msg': msg - }) - else: - print_live_status(i, total, domain, "KAUF FEHLGESCHLAGEN", f"Code {code}: {msg}") - statuses.append({ - 'domain': domain, - 'available': True, - 'action': 'purchase_failed', - 'detail': f"Code {code}: {msg}", - 'api_code': code, - 'api_msg': msg - }) - else: - print_live_status(i, total, domain, "NICHT VERFÜGBAR") - statuses.append({ - 'domain': domain, - 'available': False, - 'action': 'skipped', - 'detail': 'already registered', - 'api_code': 1000, - 'api_msg': 'domain not available' - }) + statuses.append(process_domain(i, total, domain, account_info, ns)) except Exception as e: print_live_status(i, total, domain, "FEHLER", str(e)) statuses.append({ - 'domain': domain, - 'available': None, - 'action': 'error', - 'detail': str(e), - 'api_code': None, - 'api_msg': None + "domain": domain, + "available": None, + "action": "error", + "detail": str(e), + "api_code": None, + "api_msg": None, }) - # Logout - api_client.logout() - logging.info("Logout successful.") - except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error("An error occurred: %s", e) print(f"\nAbbruch: {e}") - return + finally: + if logged_in: + try: + api_client.logout() + logging.info("Logout successful.") + except Exception as e: + logging.warning("Logout failed: %s", e) - # Summary table - print("\nZusammenfassung:") - header = f"{'Domain':40} {'Avail':5} {'Action':16} {'Detail'}" - print(header) - print("-" * len(header)) - for s in statuses: - avail = {True: "yes", False: "no"}.get(s['available'], "-") - detail = s['detail'] or "" - print(f"{s['domain']:40} {avail:5} {s['action']:16} {detail}") + if statuses: + print_summary(statuses) + write_csv(CSV_FILE, statuses) - # CSV export - try: - with open("domain_status.csv", "w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter(f, fieldnames=["domain","available","action","detail","api_code","api_msg"]) - writer.writeheader() - writer.writerows(statuses) - print('\nCSV gespeichert: domain_status.csv') - except Exception as e: - logging.error(f"Failed to write CSV: {e}") - print(f"\nKonnte CSV nicht schreiben: {e}") if __name__ == "__main__": main() diff --git a/cloudflare-worker/.dev.vars.sample b/cloudflare-worker/.dev.vars.sample new file mode 100644 index 0000000..91faada --- /dev/null +++ b/cloudflare-worker/.dev.vars.sample @@ -0,0 +1,34 @@ +# Local development secrets for `wrangler dev`. +# Copy this file to `.dev.vars` and fill it in. NEVER commit `.dev.vars`. +# In production these are set with `wrangler secret put ` instead. + +INWX_USERNAME = "your-inwx-login" +INWX_PASSWORD = "your-inwx-password" + +# Bearer token required by the HTTP endpoints (/domains, /run, /results.*). +ADMIN_TOKEN = "choose-a-long-random-token" + +# Only needed if your INWX account uses 2FA / mobile TAN. +# This is the base32 secret shown when you enable 2FA. +# INWX_SHARED_SECRET = "BASE32SECRET" + +# Optional Slack/Discord-compatible webhook for run notifications. +# NOTIFY_WEBHOOK_URL = "https://hooks.slack.com/services/..." + +# Optional Telegram notifications (both required to enable). +# TELEGRAM_BOT_TOKEN = "123456:ABC-DEF..." +# TELEGRAM_CHAT_ID = "123456789" + +# Optional dead-man's-switch: the scheduled run pings this URL on every cron +# invocation. Point it at healthchecks.io / cron-job.org etc. to be alerted if +# the cron ever stops firing. +# HEARTBEAT_URL = "https://hc-ping.com/your-uuid" + +# Optional email notifications via Resend (all three required to enable). +# RESEND_API_KEY = "re_..." +# EMAIL_TO = "you@example.com" +# EMAIL_FROM = "bot@your-verified-domain.com" + +# Optional default nameservers used on purchase. +# INWX_NS1 = "ns.inwx.de" +# INWX_NS2 = "ns2.inwx.de" diff --git a/cloudflare-worker/.gitignore b/cloudflare-worker/.gitignore new file mode 100644 index 0000000..abe5887 --- /dev/null +++ b/cloudflare-worker/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.wrangler/ +dist/ +.dev.vars +*.log diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md new file mode 100644 index 0000000..7dfb616 --- /dev/null +++ b/cloudflare-worker/README.md @@ -0,0 +1,212 @@ +# INWX Bot — Cloudflare Worker + +A serverless variant of the [`check_domain.py`](../readme.md) bot. It runs on a +**Cron Trigger** instead of being started by hand, talks to the INWX JSON-RPC +API directly via `fetch` (the Python `requests`-based SDK does not run on +Workers), and stores the domain list and results in **Workers KV**. + +## How it works + +- Two **Cron Triggers** invoke the Worker: one runs the availability check + (default 06:00 UTC), the other refreshes WHOIS (06:30 UTC). Splitting them + keeps each invocation within Cloudflare's per-request subrequest limit. +- It logs in to INWX and checks the domains from the KV list with `domain.check` + in **batches** (one subrequest per ~30 domains). Each domain has a **mode**: + `auto` registers it when free (unless `DRY_RUN`), `watch` only reports. An + optional per-domain **max price** skips auto-purchase when the INWX price + exceeds the budget. You can also buy an available domain on demand ("Kaufen"). +- Registrations use configurable options (`renewalMode`, `period`, + `transferLock`) — defaulting to auto-renew + transfer-locked. +- Results are written to KV (`results:latest.json` and `results:latest.csv`). +- It also refreshes **WHOIS/registration metadata** per domain (registered / + expires / last changed / status) and stores it in KV (`whois:latest.json`). +- Each run is recorded in a capped **history** (`history:runs`) shown in the + dashboard, and a per-domain **state** is kept (`state:domains`). +- A **web dashboard** is served at `/` to view status, edit the domain list, + trigger a run, view the WHOIS table and the run history, and download the CSV. +- Optional notifications via a Slack/Discord-compatible webhook, **Telegram** + and/or **email** (Resend) — sent only on **change** (a domain newly available / + bought / failed, or a new run error), not on every run, plus **expiry alerts** + as owned domains approach their renewal date (`EXPIRY_ALERT_DAYS` thresholds). +- A best-effort KV **lock** prevents a manual run from overlapping the cron run. +- An optional **heartbeat** (`HEARTBEAT_URL`) is pinged after every cron run, so + an external dead-man's-switch (e.g. healthchecks.io) can alert if it stalls. + +> **Safety first:** `DRY_RUN` defaults to `"true"`, so out of the box the bot +> only *reports* available domains and never spends money. Set it to `"false"` +> in `wrangler.toml` and redeploy once you are confident. + +## Prerequisites + +- A [Cloudflare account](https://dash.cloudflare.com/sign-up) (the free plan is enough). +- Node.js 18+ and the Wrangler CLI (installed locally via `npm install`). + +## Setup + +```bash +cd cloudflare-worker +npm install + +# 1. Create the KV namespace and copy the printed id into wrangler.toml +# (replace REPLACE_WITH_YOUR_KV_NAMESPACE_ID). +npx wrangler kv namespace create INWX_BOT + +# 2. Store your credentials as secrets (not in wrangler.toml). +npx wrangler secret put INWX_USERNAME +npx wrangler secret put INWX_PASSWORD +npx wrangler secret put ADMIN_TOKEN # bearer token for the HTTP endpoints +# Optional: +npx wrangler secret put INWX_SHARED_SECRET # only if 2FA / mobile TAN is enabled +npx wrangler secret put NOTIFY_WEBHOOK_URL # Slack/Discord webhook +npx wrangler secret put INWX_NS1 # default nameservers on purchase +npx wrangler secret put INWX_NS2 + +# 3. Deploy. +npx wrangler deploy + +# 4. Seed the domain list — easiest in the dashboard at +# https://inwx-bot..workers.dev/ or via the API. +# Plain list (every entry defaults to mode "auto"): +curl -X PUT https://inwx-bot..workers.dev/api/domains \ + -H "Authorization: Bearer " \ + --data-binary $'example.de\nmy-other-domain.com' +# …or full per-domain config as JSON: +curl -X PUT https://inwx-bot..workers.dev/api/domains \ + -H "Authorization: Bearer " -H "content-type: application/json" \ + --data '[{"domain":"example.de","mode":"auto","maxPrice":15},{"domain":"premium.com","mode":"watch"}]' +``` + +Each entry is `{ "domain", "mode": "auto"|"watch", "maxPrice"?, "tags"?, "notes"? }`. +Plain strings and newline lists stay supported and default to `mode: "auto"`. + +You can also set the domain list without the HTTP API: + +```bash +npx wrangler kv key put --binding INWX_BOT domains $'example.de\nfoo.com' +``` + +## Configuration + +Non-secret settings live in `wrangler.toml` under `[vars]`: + +| Variable | Default | Description | +|-----------------|--------------------------------------|-----------------------------------------------| +| `DRY_RUN` | `"true"` | When true, never registers — only reports. | +| `API_DELAY_MS` | `"1000"` | Delay between API calls (rate limiting). | +| `INWX_API_URL` | `https://api.domrobot.com/jsonrpc/` | Set to the OT&E URL to test against sandbox. | +| `EXPIRY_ALERT_DAYS`| `"30,14,7,1"` | Days-before-expiry thresholds for alerts. | +| `RENEWAL_MODE` | `"AUTORENEW"` | `domain.create` renewal mode. | +| `TRANSFER_LOCK` | `"true"` | Lock transfers on newly registered domains. | +| `PERIOD` | — | Registration period (e.g. `1Y`); empty = min. | +| `WHOIS_PORT43` | `"false"` | Opt-in port-43 WHOIS fallback (see below). | + +Secrets (set with `wrangler secret put`): `INWX_USERNAME`, `INWX_PASSWORD`, +`ADMIN_TOKEN`, and the optional `INWX_SHARED_SECRET`, `NOTIFY_WEBHOOK_URL`, +`TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` (Telegram notifications), +`RESEND_API_KEY` + `EMAIL_TO` + `EMAIL_FROM` (email notifications via Resend), +`HEARTBEAT_URL` (dead-man's-switch ping), `INWX_NS1`, `INWX_NS2`. + +Notifications are sent to every configured channel (Slack/Discord webhook and/or +Telegram). Responses carry a strict CSP and `X-Content-Type-Options: nosniff`. +Registration options and the runtime settings above can also be changed from the +dashboard without a redeploy. + +The schedule is the two-entry `crons` array in `wrangler.toml` (run + WHOIS); +keep it in sync with `RUN_CRON` / `WHOIS_CRON` in `src/index.ts`. + +## Dashboard + +Open the Worker URL (`https://inwx-bot..workers.dev/`) in a +browser. The page is public, but all data is gated behind the admin token: +enter your `ADMIN_TOKEN` once (kept in the browser's `sessionStorage`) to + +- see the last run's status and per-domain results, including the **price**, +- edit the domain list in a table (mode `auto`/`watch`, max price, tags, notes), +- **buy an available domain on demand** ("Kaufen" button — the confirmation shows + the price, the row updates immediately, and a notification is sent), +- trigger a check immediately ("Jetzt prüfen"), +- run an ad-hoc **quick check** for a domain or a keyword across several TLDs, +- view the **WHOIS / domain-status table** (registered / expires / last changed / + status per domain; expiry within 30 days is highlighted) and refresh it, +- **filter and sort** the results and WHOIS tables (type to filter, click a + column header to sort), +- review the run **history** and the **audit log**, download the CSV, or + **download a full backup** (domains + settings) for safekeeping, +- change **settings** (dry-run, API delay, expiry thresholds, registration + options) without redeploying, or **reset** them to the `wrangler.toml` defaults. + +A strict Content-Security-Policy (nonce-based, no external assets) is applied, +and the admin token is protected by per-IP brute-force throttling. + +### Settings overrides + +`wrangler.toml` `[vars]` provide the safe defaults; the dashboard (or +`PUT /api/settings`) can override `dryRun`, `apiDelayMs` and `expiryAlertDays` +at runtime — stored in KV (`settings:config`) and applied without a redeploy. +Toggling dry-run off from the dashboard asks for confirmation first. + +> **Note:** a stored override **persists across deploys** until you reset it. +> Use the dashboard's "Auf Standard zurücksetzen" button or `DELETE /api/settings` +> to drop all overrides and fall back to `wrangler.toml`. + +### WHOIS / domain status + +Registration metadata is gathered per domain from two sources: + +- **INWX `domain.info`** for domains in your own account — authoritative dates + (registered / expires / last changed) and status, works for every TLD. +- **RDAP** (the JSON successor to WHOIS) for all other domains. Some ccTLDs — + notably **.de** (DENIC) — aren't covered by RDAP; for those the table reports + the dates as *unknown* (rather than falsely "available") unless port-43 is on. +- **Port-43 WHOIS** (opt-in via `WHOIS_PORT43="true"`): a raw WHOIS query over + TCP for the handful of ccTLDs RDAP doesn't cover. It's best-effort and + time-boxed (5 s) so it can never stall a run — many registries block + datacenter IPs, so it often returns nothing, and DENIC never publishes + registration/expiry anyway (only status + last change). + +The table refreshes on each cron run and via the "WHOIS aktualisieren" button. + +## HTTP endpoints + +`GET /` (dashboard) and `GET /api/status` are public. Everything else requires +`Authorization: Bearer `. + +| Method & path | Description | +|---------------------------|----------------------------------------------------------| +| `GET /` | Web dashboard (HTML). | +| `GET /api/status` | Health/status: dry-run mode, last run time, last error. | +| `GET /api/domains` | Current domain list (array of per-domain config objects).| +| `PUT /api/domains` | Replace the list (config JSON, string array, or text). | +| `POST /api/run` | Run the check now and return the result. | +| `POST /api/run?async=true`| Start a run in the background, return `202` immediately. | +| `POST /api/buy` | Register one available domain now: `{"domain":"x.de"}`. | +| `GET /api/results.json` | Full result of the last run. | +| `GET /api/results.csv` | Last run as CSV (same columns as the Python script). | +| `GET /api/whois` | WHOIS/registration metadata per domain (last refresh). | +| `POST /api/whois/refresh` | Refresh WHOIS data now (`?async=true` for background). | +| `GET /api/history` | Recent run history (capped list, newest first). | +| `GET /api/settings` | Effective settings + the stored KV override. | +| `PUT /api/settings` | Override `dryRun` / `apiDelayMs` / `expiryAlertDays`. | +| `DELETE /api/settings` | Reset overrides to the `wrangler.toml` defaults. | +| `POST /api/check` | Ad-hoc check: `{"domain"}` or `{"keyword","tlds":[]}`. | +| `GET /api/audit` | Recent admin actions (capped, newest first). | +| `GET /api/export` | Download a backup of the domain list + settings (JSON). | +| `POST /api/import` | Restore from a backup: `{"domains":[…],"settings":{…}}`. | + +`POST /api/run`, `POST /api/whois/refresh`, `POST /api/buy` and `POST /api/check` +return `409` if another run is already in progress (best-effort KV lock). +`POST /api/buy` is an explicit action and **ignores `DRY_RUN` and the per-domain +mode** — it always attempts the real (paid) registration. After too many failed +token attempts from one IP, protected endpoints return `429` for a few minutes. + +## Local development + +```bash +cp .dev.vars.sample .dev.vars # fill in credentials (gitignored) +npm run dev # wrangler dev +npm run typecheck # tsc --noEmit +npm test # vitest run (unit tests in test/) +``` + +Tip: test against the INWX OT&E sandbox first by uncommenting `INWX_API_URL` +in `wrangler.toml`. diff --git a/cloudflare-worker/package-lock.json b/cloudflare-worker/package-lock.json new file mode 100644 index 0000000..218f272 --- /dev/null +++ b/cloudflare-worker/package-lock.json @@ -0,0 +1,2980 @@ +{ + "name": "inwx-bot-worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inwx-bot-worker", + "version": "1.0.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20241011.0", + "typescript": "^5.6.0", + "vitest": "^2.1.9", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260526.1.tgz", + "integrity": "sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260526.1.tgz", + "integrity": "sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260526.1.tgz", + "integrity": "sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260526.1.tgz", + "integrity": "sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260526.1.tgz", + "integrity": "sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260529.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260529.1.tgz", + "integrity": "sha512-33n3nsaWELSgn4DLKj1X9dwZc3kVDnO+jF/hLH9fdaXG9mQzKDeUkQaVRWLJXvrPXPa9RaIuSAFO4Zh9YOqOog==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "4.20260526.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260526.0.tgz", + "integrity": "sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260526.1", + "ws": "8.20.1", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "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", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rosie-skills": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills/-/rosie-skills-0.6.4.tgz", + "integrity": "sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "rosie-skills": "dist/bin.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "rosie-skills-darwin-arm64": "0.6.4", + "rosie-skills-freebsd-x64": "0.6.4", + "rosie-skills-linux-x64": "0.6.4" + } + }, + "node_modules/rosie-skills-darwin-arm64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-darwin-arm64/-/rosie-skills-darwin-arm64-0.6.4.tgz", + "integrity": "sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/rosie-skills-freebsd-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-freebsd-x64/-/rosie-skills-freebsd-x64-0.6.4.tgz", + "integrity": "sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/rosie-skills-linux-x64": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/rosie-skills-linux-x64/-/rosie-skills-linux-x64-0.6.4.tgz", + "integrity": "sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unenv/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260526.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260526.1.tgz", + "integrity": "sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260526.1", + "@cloudflare/workerd-darwin-arm64": "1.20260526.1", + "@cloudflare/workerd-linux-64": "1.20260526.1", + "@cloudflare/workerd-linux-arm64": "1.20260526.1", + "@cloudflare/workerd-windows-64": "1.20260526.1" + } + }, + "node_modules/wrangler": { + "version": "4.95.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.95.0.tgz", + "integrity": "sha512-vgXzFVSCdUbeCadgVXvu8fK5tzNm8T9W+7lriyGWZMx0B1+CAdr4d8JTlZszHfgjypRAHmAxb49etZGIRD9pgg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260526.0", + "path-to-regexp": "6.3.0", + "rosie-skills": "^0.6.3", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260526.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260526.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "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/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/cloudflare-worker/package.json b/cloudflare-worker/package.json new file mode 100644 index 0000000..f9ae21c --- /dev/null +++ b/cloudflare-worker/package.json @@ -0,0 +1,20 @@ +{ + "name": "inwx-bot-worker", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Cloudflare Worker that checks INWX domain availability on a schedule and optionally registers free domains.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "tail": "wrangler tail", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241011.0", + "typescript": "^5.6.0", + "vitest": "^2.1.9", + "wrangler": "^4.0.0" + } +} diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts new file mode 100644 index 0000000..d06c5d4 --- /dev/null +++ b/cloudflare-worker/src/dashboard.ts @@ -0,0 +1,762 @@ +/** + * Self-contained dashboard page served by the Worker at `/`. + * + * Everything (markup, styling, client logic) is inline so there is no build + * step and no external assets to fetch. The client talks to the same-origin + * `/api/*` endpoints. A per-request CSP nonce (see index.ts) is applied to the + * inline + + +
+

INWX Bot Dashboard

+
+ + +
+
+
+ + + +
+
INWX Bot · Cron-gesteuerter Domain-Check auf Cloudflare Workers
+ + + +`; +} diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts new file mode 100644 index 0000000..74b613d --- /dev/null +++ b/cloudflare-worker/src/index.ts @@ -0,0 +1,1115 @@ +/** + * INWX domain bot as a Cloudflare Worker. + * + * - A Cron Trigger runs the check on a schedule (see wrangler.toml). + * - The domain list and the latest results live in a KV namespace. + * - A web dashboard is served at `/`. + * - JSON/CSV endpoints under `/api/*` (bearer-token protected, except the + * public `/api/status`) let you manage the list, trigger a run, and + * download results. + * + * This mirrors the behaviour of the Python `check_domain.py` script. + */ +import { dashboardPage } from "./dashboard"; +import { InwxClient, type AccountInfo } from "./inwx"; +import { lookupWhois, type WhoisInfo } from "./whois"; + +export interface Env { + INWX_BOT: KVNamespace; + INWX_USERNAME?: string; + INWX_PASSWORD?: string; + INWX_SHARED_SECRET?: string; + INWX_API_URL?: string; + INWX_NS1?: string; + INWX_NS2?: string; + ADMIN_TOKEN?: string; + NOTIFY_WEBHOOK_URL?: string; + TELEGRAM_BOT_TOKEN?: string; + TELEGRAM_CHAT_ID?: string; + API_DELAY_MS?: string; + DRY_RUN?: string; + EXPIRY_ALERT_DAYS?: string; + RENEWAL_MODE?: string; + PERIOD?: string; + TRANSFER_LOCK?: string; + HEARTBEAT_URL?: string; + RESEND_API_KEY?: string; + EMAIL_TO?: string; + EMAIL_FROM?: string; + WHOIS_PORT43?: string; +} + +type Action = "skipped" | "purchased" | "purchase_failed" | "would_purchase" | "error"; + +interface DomainStatus { + domain: string; + available: boolean | null; + action: Action; + detail: string; + api_code: number | null; + api_msg: string | null; + price?: number | null; +} + +interface RunRecord { + timestamp: string; + dryRun: boolean; + statuses: DomainStatus[]; + error?: string; +} + +interface WhoisRecord { + timestamp: string; + domains: WhoisInfo[]; +} + +interface RunSummary { + timestamp: string; + dryRun: boolean; + total: number; + counts: Record; + error?: string; +} + +interface DomainChange { + domain: string; + from: Action | "new"; + to: Action; +} + +interface DomainState { + action?: Action; + available?: boolean | null; + /** Expiry the alert thresholds below refer to (reset when it changes). */ + expires?: string | null; + /** Expiry-alert thresholds (in days) already sent for the current expiry. */ + notifiedExpiryDays?: number[]; +} + +type StateMap = Record; + +type DomainMode = "watch" | "auto"; + +interface DomainConfig { + domain: string; + /** "auto": register when free (unless DRY_RUN); "watch": only report. */ + mode: DomainMode; + /** Skip auto-purchase if the INWX price exceeds this (in account currency). */ + maxPrice?: number; + tags?: string[]; + notes?: string; +} + +const LIVE_API_URL = "https://api.domrobot.com/jsonrpc/"; +const DOMAINS_KEY = "domains"; +const RESULTS_JSON_KEY = "results:latest.json"; +const RESULTS_CSV_KEY = "results:latest.csv"; +const WHOIS_KEY = "whois:latest.json"; +const HISTORY_KEY = "history:runs"; +const STATE_KEY = "state:domains"; +const META_KEY = "state:meta"; +const LOCK_KEY = "lock:run"; +const HISTORY_LIMIT = 50; +const DEFAULT_EXPIRY_ALERT_DAYS = [30, 14, 7, 1]; +const SETTINGS_KEY = "settings:config"; +const AUDIT_KEY = "audit:log"; +const AUDIT_LIMIT = 100; +const THROTTLE_PREFIX = "throttle:"; +const THROTTLE_MAX = 10; +const THROTTLE_WINDOW_SECONDS = 300; +/** Domains per `domain.check` call (one subrequest per chunk). */ +const CHECK_BATCH_SIZE = 30; +// Cron schedules (must match wrangler.toml). The availability check and the +// WHOIS refresh run in separate invocations so each gets its own subrequest +// budget; any other/unknown cron value runs both as a safe fallback. +const RUN_CRON = "0 6 * * *"; +const WHOIS_CRON = "30 6 * * *"; + +/** Actions that are worth a notification when a domain first reaches them. */ +const INTERESTING_ACTIONS: Action[] = ["would_purchase", "purchased", "purchase_failed", "error"]; + +/** Effective runtime settings (env defaults, possibly overridden via KV). */ +interface Settings { + dryRun: boolean; + apiDelayMs: number; + expiryAlertDays: number[]; + /** Registration options applied to domain.create. */ + renewalMode: string; + period: string; + transferLock: boolean; +} + +/** Partial overrides stored in KV via the dashboard settings panel. */ +interface SettingsOverride { + dryRun?: boolean; + apiDelayMs?: number; + expiryAlertDays?: number[]; + renewalMode?: string; + period?: string; + transferLock?: boolean; +} + +interface AuditEntry { + timestamp: string; + action: string; + detail?: string; + ip?: string; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const isTrue = (value: string | undefined) => (value ?? "").trim().toLowerCase() === "true"; + +/** Normalize one entry (plain string or object) into a DomainConfig. */ +function normalizeConfig(input: unknown): DomainConfig | null { + if (typeof input === "string") { + const domain = input.trim(); + return domain ? { domain, mode: "auto" } : null; + } + if (input && typeof input === "object") { + const obj = input as Record; + const domain = String(obj.domain ?? "").trim(); + if (!domain) return null; + const cfg: DomainConfig = { domain, mode: obj.mode === "watch" ? "watch" : "auto" }; + const maxPrice = Number(obj.maxPrice); + if (obj.maxPrice !== undefined && obj.maxPrice !== null && obj.maxPrice !== "" && Number.isFinite(maxPrice)) { + cfg.maxPrice = maxPrice; + } + if (Array.isArray(obj.tags)) { + const tags = obj.tags.map((t) => String(t).trim()).filter(Boolean); + if (tags.length > 0) cfg.tags = tags; + } + if (typeof obj.notes === "string" && obj.notes.trim()) cfg.notes = obj.notes.trim(); + return cfg; + } + return null; +} + +/** Accepts a JSON array (of strings or objects) or newline-separated domains. */ +function parseDomainConfigs(raw: string): DomainConfig[] { + const trimmed = raw.trim(); + let entries: unknown[]; + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed) as unknown; + entries = Array.isArray(parsed) ? parsed : []; + } catch { + entries = trimmed.split(/\r?\n/); + } + } else { + entries = trimmed.split(/\r?\n/); + } + return entries.map(normalizeConfig).filter((c): c is DomainConfig => c !== null); +} + +async function loadDomainConfigs(env: Env): Promise { + const raw = await env.INWX_BOT.get(DOMAINS_KEY); + return raw ? parseDomainConfigs(raw) : []; +} + +async function loadDomains(env: Env): Promise { + return (await loadDomainConfigs(env)).map((c) => c.domain); +} + +function toCsv(statuses: DomainStatus[]): string { + const fields: (keyof DomainStatus)[] = ["domain", "available", "action", "detail", "api_code", "api_msg"]; + const escape = (value: unknown): string => { + const s = value === null || value === undefined ? "" : String(value); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const lines = [fields.join(",")]; + for (const status of statuses) { + lines.push(fields.map((f) => escape(status[f])).join(",")); + } + return lines.join("\n"); +} + +function countsOf(statuses: DomainStatus[]): Record { + const counts: Record = {}; + for (const s of statuses) counts[s.action] = (counts[s.action] ?? 0) + 1; + return counts; +} + +async function readJson(env: Env, key: string, fallback: T): Promise { + const raw = await env.INWX_BOT.get(key); + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +const loadState = (env: Env) => readJson(env, STATE_KEY, {}); +const saveState = (env: Env, state: StateMap) => env.INWX_BOT.put(STATE_KEY, JSON.stringify(state)); + +/** Settings from env (the safe defaults), overlaid with KV overrides. */ +function envSettings(env: Env): Settings { + return { + dryRun: isTrue(env.DRY_RUN), + apiDelayMs: env.API_DELAY_MS ? Number(env.API_DELAY_MS) : 1000, + expiryAlertDays: parseThresholds(env.EXPIRY_ALERT_DAYS), + // Conservative registration defaults: auto-renew so a domain isn't lost, + // and lock transfers unless the user opts out. + renewalMode: env.RENEWAL_MODE || "AUTORENEW", + period: env.PERIOD ?? "", + transferLock: env.TRANSFER_LOCK !== undefined ? isTrue(env.TRANSFER_LOCK) : true, + }; +} + +function mergeSettings(base: Settings, ov: SettingsOverride): Settings { + return { + dryRun: typeof ov.dryRun === "boolean" ? ov.dryRun : base.dryRun, + apiDelayMs: + typeof ov.apiDelayMs === "number" && Number.isFinite(ov.apiDelayMs) && ov.apiDelayMs >= 0 + ? ov.apiDelayMs + : base.apiDelayMs, + expiryAlertDays: + Array.isArray(ov.expiryAlertDays) && ov.expiryAlertDays.length > 0 + ? ov.expiryAlertDays.filter((n) => Number.isFinite(n) && n >= 0).sort((a, b) => b - a) + : base.expiryAlertDays, + renewalMode: typeof ov.renewalMode === "string" && ov.renewalMode ? ov.renewalMode : base.renewalMode, + period: typeof ov.period === "string" ? ov.period : base.period, + transferLock: typeof ov.transferLock === "boolean" ? ov.transferLock : base.transferLock, + }; +} + +async function loadSettings(env: Env): Promise { + return mergeSettings(envSettings(env), await readJson(env, SETTINGS_KEY, {})); +} + +async function audit(env: Env, request: Request, action: string, detail?: string): Promise { + const entry: AuditEntry = { + timestamp: new Date().toISOString(), + action, + detail, + ip: request.headers.get("cf-connecting-ip") ?? undefined, + }; + const log = await readJson(env, AUDIT_KEY, []); + log.unshift(entry); + await env.INWX_BOT.put(AUDIT_KEY, JSON.stringify(log.slice(0, AUDIT_LIMIT))); +} + +// Brute-force protection on the admin token, keyed by client IP. Fail-open: +// any KV hiccup leaves the request allowed rather than locking the user out. +async function isThrottled(env: Env, ip: string | null): Promise { + if (!ip) return false; + const n = Number(await env.INWX_BOT.get(THROTTLE_PREFIX + ip)) || 0; + return n >= THROTTLE_MAX; +} + +async function recordAuthFailure(env: Env, ip: string | null): Promise { + if (!ip) return; + const n = (Number(await env.INWX_BOT.get(THROTTLE_PREFIX + ip)) || 0) + 1; + await env.INWX_BOT.put(THROTTLE_PREFIX + ip, String(n), { expirationTtl: THROTTLE_WINDOW_SECONDS }); +} + +const clearAuthFailures = (env: Env, ip: string | null) => + ip ? env.INWX_BOT.delete(THROTTLE_PREFIX + ip) : Promise.resolve(); + +/** Expand an ad-hoc check request into concrete domains to query. */ +function expandCheckTargets(input: { domain?: string; keyword?: string; tlds?: string[] }): string[] { + if (typeof input.domain === "string" && input.domain.trim()) { + return [input.domain.trim().toLowerCase()]; + } + const keyword = (input.keyword ?? "").trim().toLowerCase().replace(/[^a-z0-9-]/g, ""); + const tlds = (input.tlds ?? []).map((t) => String(t).trim().replace(/^\./, "").toLowerCase()).filter(Boolean); + if (!keyword || tlds.length === 0) return []; + return tlds.map((tld) => `${keyword}.${tld}`); +} + +function chunk(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +async function appendHistory(env: Env, summary: RunSummary): Promise { + const history = await readJson(env, HISTORY_KEY, []); + history.unshift(summary); + await env.INWX_BOT.put(HISTORY_KEY, JSON.stringify(history.slice(0, HISTORY_LIMIT))); +} + +// Best-effort mutual exclusion. KV is eventually consistent, so this only +// guards against the common case (an overlapping manual + scheduled run); it is +// not a hard lock. +async function acquireLock(env: Env, token: string, ttlSeconds = 1800): Promise { + if (await env.INWX_BOT.get(LOCK_KEY)) return false; + await env.INWX_BOT.put(LOCK_KEY, token, { expirationTtl: ttlSeconds }); + return true; +} + +async function releaseLock(env: Env, token: string): Promise { + if ((await env.INWX_BOT.get(LOCK_KEY)) === token) await env.INWX_BOT.delete(LOCK_KEY); +} + +async function withLock(env: Env, fn: () => Promise): Promise<{ skipped: true } | { skipped: false; result: T }> { + const token = crypto.randomUUID(); + if (!(await acquireLock(env, token))) return { skipped: true }; + try { + return { skipped: false, result: await fn() }; + } finally { + await releaseLock(env, token); + } +} + +/** Domains whose action changed into an "interesting" state since last run. */ +function detectRunChanges(prev: StateMap, statuses: DomainStatus[]): DomainChange[] { + const changes: DomainChange[] = []; + for (const s of statuses) { + const before = prev[s.domain]?.action; + if (s.action !== before && INTERESTING_ACTIONS.includes(s.action)) { + changes.push({ domain: s.domain, from: before ?? "new", to: s.action }); + } + } + return changes; +} + +function parseThresholds(raw: string | undefined): number[] { + if (!raw) return DEFAULT_EXPIRY_ALERT_DAYS; + const parsed = raw + .split(",") + .map((p) => Number(p.trim())) + .filter((n) => Number.isFinite(n) && n >= 0); + return parsed.length > 0 ? parsed.sort((a, b) => b - a) : DEFAULT_EXPIRY_ALERT_DAYS; +} + +function daysUntil(dateStr: string | null): number | null { + if (!dateStr) return null; + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + return Math.floor((d.getTime() - Date.now()) / 86_400_000); +} + +async function sendWebhook(env: Env, text: string, extra: Record = {}): Promise { + if (!env.NOTIFY_WEBHOOK_URL) return; + try { + await fetch(env.NOTIFY_WEBHOOK_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + // `text` suits Slack, `content` suits Discord — sending both is harmless. + body: JSON.stringify({ text, content: text, ...extra }), + }); + } catch { + // Notifications are best-effort. + } +} + +async function sendTelegram(env: Env, text: string): Promise { + if (!env.TELEGRAM_BOT_TOKEN || !env.TELEGRAM_CHAT_ID) return; + try { + await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chat_id: env.TELEGRAM_CHAT_ID, text, disable_web_page_preview: true }), + }); + } catch { + // Notifications are best-effort. + } +} + +async function sendEmail(env: Env, text: string): Promise { + if (!env.RESEND_API_KEY || !env.EMAIL_TO || !env.EMAIL_FROM) return; + try { + await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${env.RESEND_API_KEY}` }, + body: JSON.stringify({ from: env.EMAIL_FROM, to: [env.EMAIL_TO], subject: text.slice(0, 120), text }), + }); + } catch { + // Notifications are best-effort. + } +} + +/** True if at least one notification channel is configured. */ +function hasNotifyChannel(env: Env): boolean { + return Boolean(env.NOTIFY_WEBHOOK_URL || env.TELEGRAM_BOT_TOKEN || env.RESEND_API_KEY); +} + +/** Fan out a notification to all configured channels (webhook + Telegram + email). */ +async function notify(env: Env, text: string, extra: Record = {}): Promise { + await Promise.all([sendWebhook(env, text, extra), sendTelegram(env, text), sendEmail(env, text)]); +} + +function buyParamsFor( + domain: string, + accountInfo: AccountInfo, + ns: string[], + reg: Pick, +): Record { + const params: Record = { + domain, + registrant: accountInfo.defaultRegistrant, + admin: accountInfo.defaultAdmin, + tech: accountInfo.defaultTech, + billing: accountInfo.defaultBilling, + }; + if (ns.length > 0) params.ns = ns; + if (reg.renewalMode) params.renewalMode = reg.renewalMode; + if (reg.period) params.period = reg.period; + params.transferLock = reg.transferLock; + return params; +} + +async function processDomain( + client: InwxClient, + cfg: DomainConfig, + check: { avail: boolean; price: number | null }, + accountInfo: AccountInfo, + ns: string[], + settings: Settings, +): Promise { + const domain = cfg.domain; + const { avail, price } = check; + const priceNote = price !== null ? ` (Preis ${price})` : ""; + const notBought = (detail: string): DomainStatus => ({ + domain, + available: true, + action: "would_purchase", + detail, + api_code: null, + api_msg: null, + price, + }); + + if (!avail) { + return { domain, available: false, action: "skipped", detail: "already registered", api_code: 1000, api_msg: "domain not available", price }; + } + // Available — decide whether to actually register it. + if (cfg.mode === "watch") return notBought(`watch-only${priceNote}`); + if (settings.dryRun) return notBought(`dry run – not purchased${priceNote}`); + if (cfg.maxPrice !== undefined) { + if (price === null) return notBought(`price unknown – skipped (max ${cfg.maxPrice})`); + if (price > cfg.maxPrice) return notBought(`over budget – ${price} > max ${cfg.maxPrice}`); + } + + const { success, code, msg } = await client.buyDomain(buyParamsFor(domain, accountInfo, ns, settings)); + if (success) { + return { domain, available: true, action: "purchased", detail: `success${priceNote}`, api_code: code ?? null, api_msg: msg, price }; + } + return { domain, available: true, action: "purchase_failed", detail: `Code ${code}: ${msg}`, api_code: code ?? null, api_msg: msg, price }; +} + +async function runCheck(env: Env, settings: Settings): Promise { + if (!env.INWX_USERNAME || !env.INWX_PASSWORD) { + throw new Error("INWX_USERNAME or INWX_PASSWORD is not configured"); + } + const delayMs = settings.apiDelayMs; + const client = new InwxClient(env.INWX_API_URL || LIVE_API_URL); + const statuses: DomainStatus[] = []; + + let loggedIn = false; + try { + await client.login(env.INWX_USERNAME, env.INWX_PASSWORD, env.INWX_SHARED_SECRET); + loggedIn = true; + + const accountInfo = await client.getAccountInfo(); + const ns = [env.INWX_NS1, env.INWX_NS2].filter((v): v is string => Boolean(v)); + const configs = await loadDomainConfigs(env); + + // Availability is checked in batches (one subrequest per chunk) instead of + // one call per domain — this is what keeps large lists under the limits. + const checks = new Map(); + const batches = chunk( + configs.map((c) => c.domain), + CHECK_BATCH_SIZE, + ); + for (let b = 0; b < batches.length; b++) { + if (b > 0 && delayMs > 0) await sleep(delayMs); + const batchResult = await client.checkDomains(batches[b]); + batchResult.forEach((value, key) => checks.set(key, value)); + } + + for (let i = 0; i < configs.length; i++) { + const cfg = configs[i]; + const check = checks.get(cfg.domain.toLowerCase()) ?? { avail: false, price: null }; + try { + // Only registrations make a further API call, so a small delay between + // them is enough; pure look-ups already happened in the batch above. + if (i > 0 && delayMs > 0 && check.avail && cfg.mode === "auto" && !settings.dryRun) { + await sleep(delayMs); + } + const status = await processDomain(client, cfg, check, accountInfo, ns, settings); + statuses.push(status); + console.log(`[${i + 1}/${configs.length}] ${cfg.domain} -> ${status.action}`); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + statuses.push({ domain: cfg.domain, available: null, action: "error", detail, api_code: null, api_msg: null }); + console.error(`[${i + 1}/${configs.length}] ${cfg.domain} -> error: ${detail}`); + } + } + } finally { + if (loggedIn) { + await client.logout().catch(() => {}); + } + } + return statuses; +} + +/** + * Register a single domain on explicit user request (dashboard "Kaufen"). + * This is an explicit action and therefore ignores DRY_RUN and the per-domain + * watch/auto mode — it always attempts the real purchase. + */ +async function buyDomainNow( + env: Env, + domain: string, +): Promise<{ ok: boolean; action: Action; detail: string; code?: number; price?: number | null }> { + if (!env.INWX_USERNAME || !env.INWX_PASSWORD) { + return { ok: false, action: "error", detail: "INWX credentials not configured" }; + } + const client = new InwxClient(env.INWX_API_URL || LIVE_API_URL); + let loggedIn = false; + try { + await client.login(env.INWX_USERNAME, env.INWX_PASSWORD, env.INWX_SHARED_SECRET); + loggedIn = true; + const { avail, price } = await client.checkDomain(domain); + if (!avail) return { ok: false, action: "skipped", detail: "not available", price }; + const accountInfo = await client.getAccountInfo(); + const ns = [env.INWX_NS1, env.INWX_NS2].filter((v): v is string => Boolean(v)); + const settings = await loadSettings(env); + const { success, code, msg } = await client.buyDomain(buyParamsFor(domain, accountInfo, ns, settings)); + if (success) { + await markDomainPurchased(env, domain, price); + await notify(env, `INWX-Bot: Domain registriert – ${domain}${price !== null ? ` (Preis ${price})` : ""}`); + return { ok: true, action: "purchased", detail: "success", code, price }; + } + return { ok: false, action: "purchase_failed", detail: `Code ${code}: ${msg}`, code, price }; + } catch (e) { + return { ok: false, action: "error", detail: e instanceof Error ? e.message : String(e) }; + } finally { + if (loggedIn) await client.logout().catch(() => {}); + } +} + +/** Keep stored results/state consistent after an on-demand purchase. */ +async function markDomainPurchased(env: Env, domain: string, price: number | null): Promise { + const record = await readJson(env, RESULTS_JSON_KEY, null); + if (record && Array.isArray(record.statuses)) { + const row = record.statuses.find((s) => s.domain === domain); + if (row) { + row.action = "purchased"; + row.available = true; + row.detail = "manuell gekauft"; + row.price = price; + await env.INWX_BOT.put(RESULTS_JSON_KEY, JSON.stringify(record, null, 2)); + await env.INWX_BOT.put(RESULTS_CSV_KEY, toCsv(record.statuses)); + } + } + const state = await loadState(env); + state[domain] = { ...state[domain], action: "purchased", available: true }; + await saveState(env, state); +} + +interface CheckResult { + domain: string; + available?: boolean; + price?: number | null; + error?: string; +} + +/** Ad-hoc availability/price check for arbitrary domains (does not buy). */ +async function runAdhocCheck(env: Env, targets: string[]): Promise { + if (!env.INWX_USERNAME || !env.INWX_PASSWORD) { + throw new Error("INWX credentials not configured"); + } + const settings = await loadSettings(env); + const client = new InwxClient(env.INWX_API_URL || LIVE_API_URL); + let loggedIn = false; + try { + await client.login(env.INWX_USERNAME, env.INWX_PASSWORD, env.INWX_SHARED_SECRET); + loggedIn = true; + const checks = new Map(); + const batches = chunk(targets, CHECK_BATCH_SIZE); + for (let b = 0; b < batches.length; b++) { + if (b > 0 && settings.apiDelayMs > 0) await sleep(settings.apiDelayMs); + const result = await client.checkDomains(batches[b]); + result.forEach((value, key) => checks.set(key, value)); + } + return targets.map((domain) => { + const c = checks.get(domain.toLowerCase()); + return c ? { domain, available: c.avail, price: c.price } : { domain, error: "no result" }; + }); + } finally { + if (loggedIn) await client.logout().catch(() => {}); + } +} + +const ACTION_LABELS_DE: Record = { + skipped: "übersprungen", + would_purchase: "verfügbar", + purchased: "gekauft", + purchase_failed: "Kauf fehlgeschlagen", + error: "Fehler", +}; + +async function notifyRun(env: Env, record: RunRecord, changes: DomainChange[]): Promise { + if (!hasNotifyChannel(env)) return; + + // De-duplicate fatal run errors so a persistent failure does not spam. + if (record.error) { + const meta = await readJson<{ lastNotifiedError?: string }>(env, META_KEY, {}); + if (meta.lastNotifiedError !== record.error) { + await notify(env, `INWX-Bot: Lauf fehlgeschlagen – ${record.error}`); + await env.INWX_BOT.put(META_KEY, JSON.stringify({ ...meta, lastNotifiedError: record.error })); + } + return; + } + // Clear a stored error once a run succeeds again. + await env.INWX_BOT.put(META_KEY, JSON.stringify({})); + + // Only notify when a domain's status actually changed since the last run. + if (changes.length === 0) return; + const detail = changes.map((c) => `${c.domain}: ${ACTION_LABELS_DE[c.to]}`).join(", "); + const prefix = record.dryRun ? "INWX-Bot (Probelauf)" : "INWX-Bot"; + await notify(env, `${prefix}: ${changes.length} Änderung(en) – ${detail}`, { changes }); +} + +async function runAndStore(env: Env): Promise { + const timestamp = new Date().toISOString(); + const settings = await loadSettings(env); + const dryRun = settings.dryRun; + let record: RunRecord; + try { + record = { timestamp, dryRun, statuses: await runCheck(env, settings) }; + } catch (e) { + record = { timestamp, dryRun, statuses: [], error: e instanceof Error ? e.message : String(e) }; + } + + await env.INWX_BOT.put(RESULTS_JSON_KEY, JSON.stringify(record, null, 2)); + await env.INWX_BOT.put(RESULTS_CSV_KEY, toCsv(record.statuses)); + + // Detect per-domain changes vs. the previous run, then persist the new state. + const state = await loadState(env); + const changes = detectRunChanges(state, record.statuses); + for (const s of record.statuses) { + state[s.domain] = { ...state[s.domain], action: s.action, available: s.available }; + } + await saveState(env, state); + + await appendHistory(env, { + timestamp, + dryRun, + total: record.statuses.length, + counts: countsOf(record.statuses), + error: record.error, + }); + + await notifyRun(env, record, changes); + return record; +} + +function inwxToWhois(domain: string, info: Record): WhoisInfo { + const status = info.status; + const statusList = Array.isArray(status) + ? status.map(String) + : status + ? [String(status)] + : []; + return { + domain, + source: "inwx", + available: false, + status: statusList, + registered: (info.crDate as string) ?? null, + expires: (info.exDate as string) ?? null, + updated: (info.upDate as string) ?? null, + registrar: "INWX", + }; +} + +/** Prefer authoritative INWX data for owned domains; fall back to RDAP/WHOIS. */ +async function enrichDomain(client: InwxClient | null, domain: string, port43: boolean): Promise { + if (client) { + try { + const info = await client.getDomainInfo(domain); + if (info) return inwxToWhois(domain, info); + } catch { + // not owned / API hiccup -> fall back to RDAP + } + } + return lookupWhois(domain, { port43 }); +} + +async function refreshWhois(env: Env): Promise { + const timestamp = new Date().toISOString(); + const settings = await loadSettings(env); + const delayMs = settings.apiDelayMs; + const port43 = isTrue(env.WHOIS_PORT43); + const domains = await loadDomains(env); + + // Logging in is optional: without INWX credentials we still serve RDAP data. + let client: InwxClient | null = null; + let loggedIn = false; + if (env.INWX_USERNAME && env.INWX_PASSWORD) { + const c = new InwxClient(env.INWX_API_URL || LIVE_API_URL); + try { + await c.login(env.INWX_USERNAME, env.INWX_PASSWORD, env.INWX_SHARED_SECRET); + client = c; + loggedIn = true; + } catch { + client = null; + } + } + + const results: WhoisInfo[] = []; + try { + for (let i = 0; i < domains.length; i++) { + if (i > 0 && delayMs > 0) await sleep(delayMs); + try { + results.push(await enrichDomain(client, domains[i], port43)); + } catch (e) { + results.push({ + domain: domains[i], + source: "none", + available: null, + status: [], + registered: null, + expires: null, + updated: null, + registrar: null, + error: e instanceof Error ? e.message : String(e), + }); + } + } + } finally { + if (loggedIn && client) await client.logout().catch(() => {}); + } + + const record: WhoisRecord = { timestamp, domains: results }; + await env.INWX_BOT.put(WHOIS_KEY, JSON.stringify(record, null, 2)); + await processExpiryAlerts(env, results, settings.expiryAlertDays); + return record; +} + +/** Notify once per crossed threshold as an owned/registered domain nears expiry. */ +async function processExpiryAlerts(env: Env, results: WhoisInfo[], thresholds: number[]): Promise { + const state = await loadState(env); + const alerts: string[] = []; + + for (const w of results) { + const st: DomainState = state[w.domain] ?? {}; + // Reset the notified thresholds if the expiry date changed (e.g. renewed). + if (st.expires !== (w.expires ?? null)) { + st.expires = w.expires ?? null; + st.notifiedExpiryDays = []; + } + const left = daysUntil(w.expires); + if (left !== null && left >= 0) { + const notified = st.notifiedExpiryDays ?? []; + const crossed = thresholds.filter((t) => left <= t && !notified.includes(t)); + if (crossed.length > 0) { + alerts.push(`${w.domain}: läuft in ${left} Tag(en) ab (${(w.expires ?? "").slice(0, 10)})`); + st.notifiedExpiryDays = [...notified, ...crossed]; + } + } + state[w.domain] = st; + } + + await saveState(env, state); + if (alerts.length > 0) { + await notify(env, `INWX-Bot: Ablauf-Warnung – ${alerts.join("; ")}`, { expiring: alerts }); + } +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { "content-type": "application/json; charset=utf-8", "x-content-type-options": "nosniff" }, + }); +} + +function unauthorized(): Response { + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json", "www-authenticate": "Bearer" }, + }); +} + +function isAuthorized(request: Request, env: Env): boolean { + if (!env.ADMIN_TOKEN) return false; + const [scheme, token] = (request.headers.get("authorization") ?? "").split(" "); + return scheme === "Bearer" && token === env.ADMIN_TOKEN; +} + +async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const path = url.pathname.replace(/\/+$/, "") || "/"; + + // Web dashboard (public shell; data calls below need the admin token). + if (request.method === "GET" && (path === "/" || path === "/dashboard")) { + const nonce = crypto.randomUUID().replace(/-/g, ""); + return new Response(dashboardPage(nonce), { + headers: { + "content-type": "text/html; charset=utf-8", + "content-security-policy": + `default-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; ` + + `connect-src 'self'; img-src data:; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'`, + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }, + }); + } + + // Public, low-detail status endpoint (no domain details leaked). + if (request.method === "GET" && path === "/api/status") { + const [raw, whoisRaw, settings] = await Promise.all([ + env.INWX_BOT.get(RESULTS_JSON_KEY), + env.INWX_BOT.get(WHOIS_KEY), + loadSettings(env), + ]); + const last = raw ? (JSON.parse(raw) as RunRecord) : null; + const whois = whoisRaw ? (JSON.parse(whoisRaw) as WhoisRecord) : null; + return json({ + ok: true, + service: "inwx-bot worker", + dryRun: settings.dryRun, + authConfigured: Boolean(env.ADMIN_TOKEN), + lastRun: last?.timestamp ?? null, + lastRunDomains: last?.statuses.length ?? 0, + lastRunError: last?.error ?? null, + whoisLastRun: whois?.timestamp ?? null, + }); + } + + // Everything below requires the admin bearer token, with per-IP throttling + // to slow down brute-force attempts. + const ip = request.headers.get("cf-connecting-ip"); + if (await isThrottled(env, ip)) { + return json({ error: "too many failed attempts – try again later" }, 429); + } + if (!isAuthorized(request, env)) { + await recordAuthFailure(env, ip); + return unauthorized(); + } + await clearAuthFailures(env, ip); + + if (request.method === "GET" && path === "/api/results.csv") { + const csv = (await env.INWX_BOT.get(RESULTS_CSV_KEY)) ?? ""; + return new Response(csv, { headers: { "content-type": "text/csv; charset=utf-8" } }); + } + + if (request.method === "GET" && path === "/api/results.json") { + const raw = await env.INWX_BOT.get(RESULTS_JSON_KEY); + return new Response(raw ?? "{}", { headers: { "content-type": "application/json; charset=utf-8" } }); + } + + if (path === "/api/domains") { + if (request.method === "GET") { + return json({ domains: await loadDomainConfigs(env) }); + } + if (request.method === "PUT" || request.method === "POST") { + const domains = parseDomainConfigs(await request.text()); + await env.INWX_BOT.put(DOMAINS_KEY, JSON.stringify(domains)); + await audit(env, request, "domains.save", `${domains.length} domains`); + return json({ ok: true, count: domains.length, domains }); + } + } + + if (request.method === "POST" && path === "/api/run") { + await audit(env, request, "run", url.searchParams.get("async") === "true" ? "async" : "sync"); + // `?async=true` returns immediately and runs in the background; otherwise + // the run completes inline (suitable for small lists / manual triggers). + if (url.searchParams.get("async") === "true") { + ctx.waitUntil(withLock(env, () => runAndStore(env)).then(() => undefined)); + return json({ ok: true, started: true }, 202); + } + const outcome = await withLock(env, () => runAndStore(env)); + if (outcome.skipped) return json({ ok: false, error: "a run is already in progress" }, 409); + return json({ ok: !outcome.result.error, ...outcome.result }); + } + + if (request.method === "POST" && path === "/api/buy") { + let body: { domain?: string } = {}; + try { + body = (await request.json()) as { domain?: string }; + } catch { + // ignore malformed body + } + const domain = (body.domain ?? "").trim(); + if (!domain) return json({ ok: false, error: "domain required" }, 400); + await audit(env, request, "buy", domain); + const outcome = await withLock(env, () => buyDomainNow(env, domain)); + if (outcome.skipped) return json({ ok: false, error: "a run is already in progress" }, 409); + return json({ domain, ...outcome.result }); + } + + if (request.method === "GET" && path === "/api/whois") { + const raw = await env.INWX_BOT.get(WHOIS_KEY); + return new Response(raw ?? "{}", { headers: { "content-type": "application/json; charset=utf-8" } }); + } + + if (request.method === "POST" && path === "/api/whois/refresh") { + await audit(env, request, "whois.refresh"); + if (url.searchParams.get("async") === "true") { + ctx.waitUntil(withLock(env, () => refreshWhois(env)).then(() => undefined)); + return json({ ok: true, started: true }, 202); + } + const outcome = await withLock(env, () => refreshWhois(env)); + if (outcome.skipped) return json({ ok: false, error: "a refresh is already in progress" }, 409); + return json({ ok: true, ...outcome.result }); + } + + if (request.method === "GET" && path === "/api/history") { + const raw = await env.INWX_BOT.get(HISTORY_KEY); + return new Response(raw ?? "[]", { headers: { "content-type": "application/json; charset=utf-8" } }); + } + + if (path === "/api/settings") { + if (request.method === "GET") { + const [effective, override] = await Promise.all([ + loadSettings(env), + readJson(env, SETTINGS_KEY, {}), + ]); + return json({ effective, override }); + } + if (request.method === "PUT" || request.method === "POST") { + let body: SettingsOverride = {}; + try { + body = (await request.json()) as SettingsOverride; + } catch { + // ignore malformed body + } + const override: SettingsOverride = {}; + if (typeof body.dryRun === "boolean") override.dryRun = body.dryRun; + if (typeof body.apiDelayMs === "number" && Number.isFinite(body.apiDelayMs) && body.apiDelayMs >= 0) { + override.apiDelayMs = body.apiDelayMs; + } + if (Array.isArray(body.expiryAlertDays)) { + const days = body.expiryAlertDays.map(Number).filter((n) => Number.isFinite(n) && n >= 0); + if (days.length > 0) override.expiryAlertDays = days; + } + if (typeof body.renewalMode === "string" && body.renewalMode) override.renewalMode = body.renewalMode; + if (typeof body.period === "string") override.period = body.period; + if (typeof body.transferLock === "boolean") override.transferLock = body.transferLock; + await env.INWX_BOT.put(SETTINGS_KEY, JSON.stringify(override)); + await audit(env, request, "settings.save", JSON.stringify(override)); + return json({ ok: true, override, effective: await loadSettings(env) }); + } + if (request.method === "DELETE") { + // Drop all overrides and fall back to the wrangler.toml defaults. + await env.INWX_BOT.delete(SETTINGS_KEY); + await audit(env, request, "settings.reset"); + return json({ ok: true, override: {}, effective: await loadSettings(env) }); + } + } + + if (request.method === "POST" && path === "/api/check") { + let body: { domain?: string; keyword?: string; tlds?: string[] } = {}; + try { + body = (await request.json()) as { domain?: string; keyword?: string; tlds?: string[] }; + } catch { + // ignore malformed body + } + const targets = expandCheckTargets(body); + if (targets.length === 0) return json({ ok: false, error: "provide a domain, or a keyword + tlds" }, 400); + if (!env.INWX_USERNAME || !env.INWX_PASSWORD) { + return json({ ok: false, error: "INWX credentials not configured" }); + } + await audit(env, request, "check", targets.join(",")); + try { + const outcome = await withLock(env, () => runAdhocCheck(env, targets)); + if (outcome.skipped) return json({ ok: false, error: "a run is already in progress" }, 409); + return json({ ok: true, results: outcome.result }); + } catch (e) { + return json({ ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + + if (request.method === "GET" && path === "/api/audit") { + const raw = await env.INWX_BOT.get(AUDIT_KEY); + return new Response(raw ?? "[]", { + headers: { "content-type": "application/json; charset=utf-8", "x-content-type-options": "nosniff" }, + }); + } + + if (request.method === "GET" && path === "/api/export") { + const [domains, settings] = await Promise.all([ + loadDomainConfigs(env), + readJson(env, SETTINGS_KEY, {}), + ]); + return new Response(JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), domains, settings }, null, 2), { + headers: { + "content-type": "application/json; charset=utf-8", + "content-disposition": 'attachment; filename="inwx-bot-backup.json"', + "x-content-type-options": "nosniff", + }, + }); + } + + if (request.method === "POST" && path === "/api/import") { + let body: { domains?: unknown; settings?: SettingsOverride } = {}; + try { + body = (await request.json()) as { domains?: unknown; settings?: SettingsOverride }; + } catch { + return json({ ok: false, error: "invalid JSON" }, 400); + } + const restored: string[] = []; + if (Array.isArray(body.domains)) { + const domains = body.domains.map(normalizeConfig).filter((c): c is DomainConfig => c !== null); + await env.INWX_BOT.put(DOMAINS_KEY, JSON.stringify(domains)); + restored.push(`${domains.length} domains`); + } + if (body.settings && typeof body.settings === "object") { + await env.INWX_BOT.put(SETTINGS_KEY, JSON.stringify(body.settings)); + restored.push("settings"); + } + await audit(env, request, "import", restored.join(", ") || "nothing"); + return json({ ok: true, restored }); + } + + return json({ error: "not found" }, 404); +} + +/** Dead-man's-switch ping: an external monitor alerts if these stop arriving. */ +async function pingHeartbeat(env: Env): Promise { + if (!env.HEARTBEAT_URL) return; + try { + await fetch(env.HEARTBEAT_URL, { method: "GET" }); + } catch { + // best-effort + } +} + +export default { + async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise { + // Split the work across cron triggers so each invocation gets its own + // subrequest budget. Unknown cron values fall back to running both. + let task: () => Promise; + if (event.cron === RUN_CRON) { + task = async () => { + await runAndStore(env); + }; + } else if (event.cron === WHOIS_CRON) { + task = async () => { + await refreshWhois(env); + }; + } else { + task = async () => { + await runAndStore(env); + await refreshWhois(env); + }; + } + // A lock guards against overlap with a manual run; the heartbeat fires + // afterwards regardless, so a stalled cron is detected externally. + ctx.waitUntil(withLock(env, task).then(() => pingHeartbeat(env))); + }, + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + return handleFetch(request, env, ctx); + }, +} satisfies ExportedHandler; + +// Re-exported for unit tests (see test/). +export { + normalizeConfig, + parseDomainConfigs, + toCsv, + countsOf, + detectRunChanges, + parseThresholds, + daysUntil, + mergeSettings, + expandCheckTargets, + chunk, +}; +export type { DomainConfig, DomainStatus, Action, StateMap, Settings, SettingsOverride }; diff --git a/cloudflare-worker/src/inwx.ts b/cloudflare-worker/src/inwx.ts new file mode 100644 index 0000000..25d4d0b --- /dev/null +++ b/cloudflare-worker/src/inwx.ts @@ -0,0 +1,175 @@ +/** + * Minimal INWX DomRobot JSON-RPC client built on the `fetch` API so it runs + * inside a Cloudflare Worker (where the Python `requests`-based SDK cannot). + * + * The session is cookie based: `account.login` returns a `domrobot` cookie + * that must be sent on every subsequent call. Workers' `fetch` does not keep + * a cookie jar, so we capture and replay it manually. + */ +import { generateTotp } from "./totp"; + +export interface InwxResponse { + code?: number; + msg?: string; + resData?: T; +} + +export interface AccountInfo { + defaultRegistrant?: number; + defaultAdmin?: number; + defaultTech?: number; + defaultBilling?: number; + [key: string]: unknown; +} + +export class InwxApiError extends Error { + constructor( + public readonly code: number | undefined, + public readonly msg: string, + public readonly context = "", + ) { + super(`API error ${context}. Code: ${code}, Message: ${msg}`); + this.name = "InwxApiError"; + } +} + +function extractSessionCookie(res: Response): string | null { + const headers = res.headers as Headers & { getSetCookie?: () => string[] }; + let cookies: string[] = []; + if (typeof headers.getSetCookie === "function") { + cookies = headers.getSetCookie(); + } else { + const single = res.headers.get("set-cookie"); + if (single) cookies = [single]; + } + for (const cookie of cookies) { + const match = cookie.match(/domrobot=[^;]+/); + if (match) return match[0]; + } + // Fallback: replay the first cookie's name=value pair. + if (cookies.length > 0) { + const first = cookies[0].split(";")[0]?.trim(); + if (first) return first; + } + return null; +} + +export class InwxClient { + private cookie: string | null = null; + + constructor( + private readonly apiUrl: string, + private readonly lang = "en", + ) {} + + private async rawCall(method: string, params: Record = {}): Promise { + const headers: Record = { "content-type": "application/json" }; + if (this.cookie) headers["cookie"] = this.cookie; + + const res = await fetch(this.apiUrl, { + method: "POST", + headers, + body: JSON.stringify({ method, params }), + }); + + const sessionCookie = extractSessionCookie(res); + if (sessionCookie) this.cookie = sessionCookie; + + if (!res.ok) { + throw new InwxApiError(res.status, `HTTP ${res.status} ${res.statusText}`, `calling ${method}`); + } + return (await res.json()) as InwxResponse; + } + + /** Call an API method and require a 1000 ("Command completed") response. */ + async call(method: string, params: Record = {}, context = ""): Promise { + const result = await this.rawCall(method, params); + if (result.code === 1000) return result; + throw new InwxApiError(result.code, result.msg ?? "", context); + } + + async login(user: string, pass: string, sharedSecret?: string): Promise { + const result = await this.rawCall("account.login", { user, pass, lang: this.lang }); + if (result.code !== 1000) { + throw new InwxApiError(result.code, result.msg ?? "", "during login"); + } + // Optional mobile-TAN / 2FA step. + const tfa = (result.resData as { tfa?: string } | undefined)?.tfa; + if (tfa && tfa !== "0") { + if (!sharedSecret) { + throw new InwxApiError(result.code, "2FA required but INWX_SHARED_SECRET is not set", "during login"); + } + const tan = await generateTotp(sharedSecret); + await this.call("account.unlock", { tan }, "during 2FA unlock"); + } + } + + async logout(): Promise { + try { + await this.rawCall("account.logout"); + } finally { + this.cookie = null; + } + } + + /** + * Check availability and (best-effort) price in a single `domain.check`. + * `price` is null when INWX does not return one for this domain. + */ + private static priceFromEntry(entry: Record): number | null { + const raw = entry.price ?? entry.checkPrice ?? null; + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw))) return Number(raw); + return null; + } + + async checkDomain(domain: string): Promise<{ avail: boolean; price: number | null }> { + const result = await this.call("domain.check", { domain }, "during domain check"); + const entry = (result.resData as { domain?: Array> } | undefined)?.domain?.[0] ?? {}; + return { avail: Boolean(entry.avail), price: InwxClient.priceFromEntry(entry) }; + } + + /** + * Check several domains in a single `domain.check` call (INWX accepts a list), + * cutting one subrequest per domain down to one per call. Results are keyed by + * lowercased domain name. + */ + async checkDomains(domains: string[]): Promise> { + const out = new Map(); + if (domains.length === 0) return out; + const result = await this.call("domain.check", { domain: domains }, "during domain check"); + const entries = (result.resData as { domain?: Array> } | undefined)?.domain ?? []; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const name = typeof entry.domain === "string" && entry.domain ? entry.domain : domains[i]; + if (!name) continue; + out.set(name.toLowerCase(), { avail: Boolean(entry.avail), price: InwxClient.priceFromEntry(entry) }); + } + return out; + } + + async isDomainFree(domain: string): Promise { + return (await this.checkDomain(domain)).avail; + } + + async getAccountInfo(): Promise { + const result = await this.call("account.info", {}, "while fetching account info"); + return (result.resData ?? {}) as AccountInfo; + } + + /** + * Fetch details for a domain in the account (registration / expiry / update + * dates, status, …). Returns null if the domain is not in this account. + */ + async getDomainInfo(domain: string): Promise | null> { + const result = await this.rawCall("domain.info", { domain }); + if (result.code === 1000) return (result.resData ?? {}) as Record; + return null; + } + + /** Attempt to register a domain. Never throws; returns the API outcome. */ + async buyDomain(buyParams: Record): Promise<{ success: boolean; code?: number; msg: string }> { + const result = await this.rawCall("domain.create", buyParams); + return { success: result.code === 1000, code: result.code, msg: result.msg ?? "" }; + } +} diff --git a/cloudflare-worker/src/totp.ts b/cloudflare-worker/src/totp.ts new file mode 100644 index 0000000..bae4dfd --- /dev/null +++ b/cloudflare-worker/src/totp.ts @@ -0,0 +1,66 @@ +/** + * RFC 6238 TOTP generation using the Web Crypto API. + * + * INWX uses this for the optional "mobile TAN" two-factor authentication. + * The shared secret is the base32 string shown when you enable 2FA. + */ + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +function base32Decode(input: string): Uint8Array { + const clean = input + .replace(/=+$/, "") + .toUpperCase() + .replace(/\s+/g, ""); + const output = new Uint8Array(Math.floor((clean.length * 5) / 8)); + let bits = 0; + let value = 0; + let index = 0; + for (const char of clean) { + const idx = BASE32_ALPHABET.indexOf(char); + if (idx === -1) continue; + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + output[index++] = (value >>> (bits - 8)) & 0xff; + bits -= 8; + } + } + return output; +} + +export interface TotpOptions { + timeStep?: number; + digits?: number; + timestampMs?: number; +} + +export async function generateTotp(secret: string, options: TotpOptions = {}): Promise { + const { timeStep = 30, digits = 6, timestampMs = Date.now() } = options; + const key = base32Decode(secret); + const counter = Math.floor(timestampMs / 1000 / timeStep); + + const counterBytes = new ArrayBuffer(8); + const view = new DataView(counterBytes); + view.setUint32(0, Math.floor(counter / 2 ** 32), false); + view.setUint32(4, counter >>> 0, false); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: "SHA-1" }, + false, + ["sign"], + ); + const signature = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, counterBytes)); + + // Dynamic truncation (RFC 4226 §5.3). + const offset = signature[signature.length - 1] & 0x0f; + const binary = + ((signature[offset] & 0x7f) << 24) | + ((signature[offset + 1] & 0xff) << 16) | + ((signature[offset + 2] & 0xff) << 8) | + (signature[offset + 3] & 0xff); + + return (binary % 10 ** digits).toString().padStart(digits, "0"); +} diff --git a/cloudflare-worker/src/whois.ts b/cloudflare-worker/src/whois.ts new file mode 100644 index 0000000..7061888 --- /dev/null +++ b/cloudflare-worker/src/whois.ts @@ -0,0 +1,254 @@ +/** + * WHOIS-style domain metadata via RDAP (the modern, JSON-over-HTTPS successor + * to WHOIS). Works from a Worker with plain `fetch`. + * + * We query the rdap.org bootstrap which redirects to the authoritative RDAP + * server for the domain's TLD. Note: some ccTLDs (notably .de / DENIC) do not + * publish registration/expiry dates over RDAP — those fields stay null. For + * domains in your own INWX account, `domain.info` is used instead (see index.ts). + */ + +export interface WhoisInfo { + domain: string; + source: "inwx" | "rdap" | "whois" | "none"; + available: boolean | null; + status: string[]; + registered: string | null; + expires: string | null; + updated: string | null; + registrar: string | null; + error?: string; +} + +interface RdapEvent { + eventAction?: string; + eventDate?: string; +} + +interface RdapEntity { + roles?: string[]; + vcardArray?: unknown; +} + +export interface RdapResponse { + events?: RdapEvent[]; + status?: string[]; + entities?: RdapEntity[]; +} + +function baseInfo(domain: string): WhoisInfo { + return { + domain, + source: "none", + available: null, + status: [], + registered: null, + expires: null, + updated: null, + registrar: null, + }; +} + +function eventDate(events: RdapEvent[] | undefined, action: string): string | null { + if (!events) return null; + const found = events.find((ev) => (ev.eventAction ?? "").toLowerCase() === action); + return found?.eventDate ?? null; +} + +/** Extract a field (e.g. "fn") from a jCard / vcardArray structure. */ +function vcardField(vcardArray: unknown, field: string): string | null { + if (!Array.isArray(vcardArray) || vcardArray.length < 2) return null; + const entries = vcardArray[1]; + if (!Array.isArray(entries)) return null; + for (const item of entries) { + if (Array.isArray(item) && item[0] === field && typeof item[3] === "string") { + return item[3]; + } + } + return null; +} + +function registrarName(entities: RdapEntity[] | undefined): string | null { + if (!entities) return null; + for (const entity of entities) { + if ((entity.roles ?? []).includes("registrar")) { + const name = vcardField(entity.vcardArray, "fn"); + if (name) return name; + } + } + return null; +} + +export function parseRdap(domain: string, data: RdapResponse): WhoisInfo { + return { + domain, + source: "rdap", + available: false, + status: Array.isArray(data.status) ? data.status : [], + registered: eventDate(data.events, "registration"), + expires: eventDate(data.events, "expiration"), + updated: eventDate(data.events, "last changed"), + registrar: registrarName(data.entities), + }; +} + +export async function lookupRdap(domain: string): Promise { + try { + const res = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, { + headers: { + accept: "application/rdap+json", + // Some RDAP servers reject requests without a User-Agent (HTTP 403). + "user-agent": "inwx-bot-worker (+https://github.com/koljasagorski/inwx_bot)", + }, + redirect: "follow", + }); + if (res.status === 404) { + // RDAP "not found" generally means the domain is unregistered. + return { ...baseInfo(domain), source: "rdap", available: true }; + } + if (!res.ok) { + return { ...baseInfo(domain), error: `RDAP HTTP ${res.status}` }; + } + const data = (await res.json()) as RdapResponse; + return parseRdap(domain, data); + } catch (e) { + return { ...baseInfo(domain), error: e instanceof Error ? e.message : String(e) }; + } +} + +// --------------------------------------------------------------------------- +// Port-43 WHOIS fallback (for ccTLDs whose RDAP lacks dates). Best-effort: any +// failure returns null and the RDAP result stands. .de (DENIC) does not publish +// registration/expiry, but status and last-change are filled. +// --------------------------------------------------------------------------- + +const WHOIS_SERVERS: Record = { + de: "whois.denic.de", + at: "whois.nic.at", + eu: "whois.eu", + nl: "whois.domain-registry.nl", +}; + +export function whoisServerFor(domain: string): string | null { + const tld = domain.split(".").pop()?.toLowerCase() ?? ""; + return WHOIS_SERVERS[tld] ?? null; +} + +const WHOIS_REGISTERED_KEYS = ["creation date", "created", "registered on", "domain registration date", "registered"]; +const WHOIS_EXPIRES_KEYS = [ + "registry expiry date", + "registrar registration expiration date", + "expiry date", + "expiration date", + "expire date", + "expires", + "paid-till", + "renewal date", +]; +const WHOIS_UPDATED_KEYS = ["updated date", "last updated", "last-update", "last modified", "changed"]; + +/** Parse a raw port-43 WHOIS response into the fields we track. */ +export function parseWhoisText(text: string): Pick { + const out: Pick = { + registered: null, + expires: null, + updated: null, + status: [], + }; + for (const line of text.split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim().toLowerCase(); + const value = line.slice(idx + 1).trim(); + if (!value) continue; + if (key === "domain status" || key === "status") { + out.status.push(value); + } else if (!out.registered && WHOIS_REGISTERED_KEYS.includes(key)) { + out.registered = value; + } else if (!out.expires && WHOIS_EXPIRES_KEYS.includes(key)) { + out.expires = value; + } else if (!out.updated && WHOIS_UPDATED_KEYS.includes(key)) { + out.updated = value; + } + } + return out; +} + +async function readWhois(socket: { writable: WritableStream; readable: ReadableStream }, server: string, domain: string): Promise { + const writer = socket.writable.getWriter(); + const query = (server === "whois.denic.de" ? `-T dn ${domain}` : domain) + "\r\n"; + await writer.write(new TextEncoder().encode(query)); + writer.releaseLock(); + + const reader = socket.readable.getReader(); + const decoder = new TextDecoder(); + let text = ""; + for (let i = 0; i < 200; i++) { + const { value, done } = await reader.read(); + if (done) break; + if (value) text += decoder.decode(value as Uint8Array, { stream: true }); + if (text.length > 100_000) break; + } + return text; +} + +async function lookupPort43(domain: string, timeoutMs = 5000): Promise | null> { + const server = whoisServerFor(domain); + if (!server) return null; + try { + // Dynamic import so importing this module in Node (tests) doesn't fail. + const { connect } = await import(/* @vite-ignore */ "cloudflare:sockets"); + const socket = connect({ hostname: server, port: 43 }); + // Race the exchange against a timeout so a non-responsive (or blocked) + // WHOIS server can never stall the refresh. `.catch` swallows a late + // rejection once the timeout has already won the race. + const exchange = readWhois(socket, server, domain).catch(() => null); + const text = await Promise.race([ + exchange, + new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)), + ]); + // Close in the background — awaiting a stuck connection's close would + // re-introduce the very hang the timeout is meant to prevent. + void Promise.resolve(socket.close()).catch(() => {}); + if (!text) return null; + const parsed = parseWhoisText(text); + if (!parsed.registered && !parsed.expires && !parsed.updated && parsed.status.length === 0) return null; + return { source: "whois", ...parsed }; + } catch { + return null; + } +} + +/** + * RDAP first; for known ccTLDs without usable RDAP, fall back to port-43 WHOIS. + * + * For gTLDs we trust RDAP entirely (including a 404 meaning "unregistered"). + * But rdap.org has no server for e.g. .de, so a 404 there is meaningless — for + * the ccTLDs in WHOIS_SERVERS we therefore consult port-43, which also tells us + * whether the domain is actually free (DENIC reports "Status: free"). + */ +export async function lookupWhois(domain: string, opts: { port43?: boolean } = {}): Promise { + const rdap = await lookupRdap(domain); + if (rdap.registered || rdap.expires) return rdap; + if (whoisServerFor(domain) === null) return rdap; // gTLD: RDAP is authoritative + + // ccTLD that rdap.org doesn't really cover (e.g. .de): its 404→"available" is + // not trustworthy. If port-43 is enabled, use it; otherwise report "unknown" + // rather than falsely flagging a registered domain as available. + if (opts.port43) { + const p43 = await lookupPort43(domain); + if (p43) { + const free = (p43.status ?? []).some((s) => /\b(free|available)\b/i.test(s)); + return { + ...rdap, + source: "whois", + available: free, + registered: p43.registered ?? null, + expires: p43.expires ?? null, + updated: p43.updated ?? null, + status: p43.status && p43.status.length > 0 ? p43.status : rdap.status, + }; + } + } + return { ...rdap, available: rdap.available === true ? null : rdap.available }; +} diff --git a/cloudflare-worker/test/core.test.ts b/cloudflare-worker/test/core.test.ts new file mode 100644 index 0000000..e27aa83 --- /dev/null +++ b/cloudflare-worker/test/core.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeConfig, + parseDomainConfigs, + toCsv, + countsOf, + detectRunChanges, + parseThresholds, + daysUntil, + type DomainStatus, + type StateMap, +} from "../src/index"; + +const status = (domain: string, action: DomainStatus["action"]): DomainStatus => ({ + domain, + available: null, + action, + detail: "", + api_code: null, + api_msg: null, +}); + +describe("normalizeConfig", () => { + it("turns a plain string into auto mode", () => { + expect(normalizeConfig("a.de")).toEqual({ domain: "a.de", mode: "auto" }); + }); + it("returns null for blank input", () => { + expect(normalizeConfig(" ")).toBeNull(); + expect(normalizeConfig({ mode: "watch" })).toBeNull(); + }); + it("defaults an unknown mode to auto", () => { + expect(normalizeConfig({ domain: "a.de", mode: "bogus" })).toEqual({ domain: "a.de", mode: "auto" }); + }); + it("ignores a non-numeric maxPrice but keeps numeric ones and trims tags", () => { + expect(normalizeConfig({ domain: "a.de", maxPrice: "x" })).toEqual({ domain: "a.de", mode: "auto" }); + expect(normalizeConfig({ domain: "a.de", mode: "watch", maxPrice: 9.5, tags: [" t1 ", ""] })).toEqual({ + domain: "a.de", + mode: "watch", + maxPrice: 9.5, + tags: ["t1"], + }); + }); +}); + +describe("parseDomainConfigs", () => { + it("parses newline text as auto mode and strips blanks", () => { + expect(parseDomainConfigs("foo.de\n\n bar.de \n")).toEqual([ + { domain: "foo.de", mode: "auto" }, + { domain: "bar.de", mode: "auto" }, + ]); + }); + it("parses a JSON array of strings", () => { + expect(parseDomainConfigs('["a.de","b.de"]')).toEqual([ + { domain: "a.de", mode: "auto" }, + { domain: "b.de", mode: "auto" }, + ]); + }); + it("parses config objects", () => { + expect(parseDomainConfigs('[{"domain":"x.de","mode":"watch","maxPrice":15,"tags":["a"]}]')).toEqual([ + { domain: "x.de", mode: "watch", maxPrice: 15, tags: ["a"] }, + ]); + }); +}); + +describe("toCsv", () => { + it("writes the header and escapes commas/quotes", () => { + const csv = toCsv([ + { domain: "foo.de", available: true, action: "purchased", detail: 'a, "b"', api_code: 1000, api_msg: "ok" }, + ]); + const lines = csv.split("\n"); + expect(lines[0]).toBe("domain,available,action,detail,api_code,api_msg"); + expect(lines[1]).toBe('foo.de,true,purchased,"a, ""b""",1000,ok'); + }); +}); + +describe("countsOf", () => { + it("counts actions", () => { + expect(countsOf([status("a", "would_purchase"), status("b", "skipped"), status("c", "would_purchase")])).toEqual({ + would_purchase: 2, + skipped: 1, + }); + }); +}); + +describe("detectRunChanges", () => { + it("flags a newly interesting action", () => { + expect(detectRunChanges({}, [status("a.de", "would_purchase")])).toEqual([ + { domain: "a.de", from: "new", to: "would_purchase" }, + ]); + }); + it("de-duplicates an unchanged available status", () => { + const prev: StateMap = { "a.de": { action: "would_purchase" } }; + expect(detectRunChanges(prev, [status("a.de", "would_purchase")])).toEqual([]); + }); + it("ignores skipped domains", () => { + expect(detectRunChanges({}, [status("a.de", "skipped")])).toEqual([]); + }); +}); + +describe("parseThresholds", () => { + it("parses and sorts descending", () => { + expect(parseThresholds("7,30,1,14")).toEqual([30, 14, 7, 1]); + }); + it("defaults when empty or undefined", () => { + expect(parseThresholds(undefined)).toEqual([30, 14, 7, 1]); + expect(parseThresholds("")).toEqual([30, 14, 7, 1]); + }); + it("filters out junk values", () => { + expect(parseThresholds("5, x, 10")).toEqual([10, 5]); + }); +}); + +describe("daysUntil", () => { + it("returns null for empty or invalid dates", () => { + expect(daysUntil(null)).toBeNull(); + expect(daysUntil("nonsense")).toBeNull(); + }); + it("computes days for a future date", () => { + const d = new Date(Date.now() + 10 * 86_400_000).toISOString(); + const left = daysUntil(d); + expect(left).toBeGreaterThanOrEqual(9); + expect(left).toBeLessThanOrEqual(10); + }); +}); diff --git a/cloudflare-worker/test/settings.test.ts b/cloudflare-worker/test/settings.test.ts new file mode 100644 index 0000000..e6d7a0c --- /dev/null +++ b/cloudflare-worker/test/settings.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { mergeSettings, expandCheckTargets, chunk, type Settings } from "../src/index"; + +const base: Settings = { + dryRun: true, + apiDelayMs: 1000, + expiryAlertDays: [30, 14, 7, 1], + renewalMode: "AUTORENEW", + period: "", + transferLock: true, +}; + +describe("mergeSettings", () => { + it("returns the base when there is no override", () => { + expect(mergeSettings(base, {})).toEqual(base); + }); + it("lets an override turn dry-run off", () => { + expect(mergeSettings(base, { dryRun: false }).dryRun).toBe(false); + }); + it("ignores an invalid (negative) delay", () => { + expect(mergeSettings(base, { apiDelayMs: -5 }).apiDelayMs).toBe(1000); + }); + it("accepts and sorts override thresholds", () => { + expect(mergeSettings(base, { expiryAlertDays: [7, 60, 1] }).expiryAlertDays).toEqual([60, 7, 1]); + }); + it("merges registration options", () => { + const merged = mergeSettings(base, { renewalMode: "AUTODELETE", transferLock: false, period: "2Y" }); + expect(merged.renewalMode).toBe("AUTODELETE"); + expect(merged.transferLock).toBe(false); + expect(merged.period).toBe("2Y"); + }); +}); + +describe("expandCheckTargets", () => { + it("normalizes a single domain to lowercase", () => { + expect(expandCheckTargets({ domain: "Example.DE" })).toEqual(["example.de"]); + }); + it("expands a keyword across tlds, stripping junk and leading dots", () => { + expect(expandCheckTargets({ keyword: "My Brand!", tlds: [".de", "com", " "] })).toEqual([ + "mybrand.de", + "mybrand.com", + ]); + }); + it("returns nothing without enough input", () => { + expect(expandCheckTargets({})).toEqual([]); + expect(expandCheckTargets({ keyword: "x" })).toEqual([]); + }); +}); + +describe("chunk", () => { + it("splits into batches of the given size", () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + it("handles empty and exact multiples", () => { + expect(chunk([], 3)).toEqual([]); + expect(chunk([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]); + }); +}); diff --git a/cloudflare-worker/test/totp.test.ts b/cloudflare-worker/test/totp.test.ts new file mode 100644 index 0000000..93c5c46 --- /dev/null +++ b/cloudflare-worker/test/totp.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { generateTotp } from "../src/totp"; + +// RFC 6238 test vectors for the SHA-1 secret ASCII "12345678901234567890". +const SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + +describe("generateTotp (RFC 6238)", () => { + it("matches the T=59 vector", async () => { + expect(await generateTotp(SECRET, { timestampMs: 59_000 })).toBe("287082"); + }); + it("matches the T=1111111109 vector", async () => { + expect(await generateTotp(SECRET, { timestampMs: 1_111_111_109_000 })).toBe("081804"); + }); +}); diff --git a/cloudflare-worker/test/whois.test.ts b/cloudflare-worker/test/whois.test.ts new file mode 100644 index 0000000..8aaba3c --- /dev/null +++ b/cloudflare-worker/test/whois.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { parseRdap, lookupRdap, parseWhoisText, whoisServerFor } from "../src/whois"; + +describe("parseRdap", () => { + it("extracts events, status and registrar", () => { + const out = parseRdap("example.com", { + status: ["client transfer prohibited"], + events: [ + { eventAction: "registration", eventDate: "1995-08-14T04:00:00Z" }, + { eventAction: "expiration", eventDate: "2026-08-13T04:00:00Z" }, + { eventAction: "last changed", eventDate: "2026-01-16T18:26:50Z" }, + ], + entities: [ + { + roles: ["registrar"], + vcardArray: ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Example Registrar"]]], + }, + ], + }); + expect(out).toMatchObject({ + domain: "example.com", + source: "rdap", + available: false, + registered: "1995-08-14T04:00:00Z", + expires: "2026-08-13T04:00:00Z", + updated: "2026-01-16T18:26:50Z", + registrar: "Example Registrar", + status: ["client transfer prohibited"], + }); + }); +}); + +describe("lookupRdap", () => { + afterEach(() => vi.restoreAllMocks()); + + it("treats HTTP 404 as available", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 404 }))); + const out = await lookupRdap("free-xyz-9182736455.com"); + expect(out.available).toBe(true); + expect(out.source).toBe("rdap"); + }); + + it("reports other HTTP errors", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 403 }))); + const out = await lookupRdap("x.com"); + expect(out.error).toContain("403"); + }); +}); + +describe("whoisServerFor", () => { + it("maps known ccTLDs and skips the rest (RDAP covers gTLDs)", () => { + expect(whoisServerFor("example.de")).toBe("whois.denic.de"); + expect(whoisServerFor("EXAMPLE.AT")).toBe("whois.nic.at"); + expect(whoisServerFor("example.com")).toBeNull(); + }); +}); + +describe("parseWhoisText", () => { + it("extracts dates and status from a gTLD-style response", () => { + const out = parseWhoisText( + [ + "Domain Name: EXAMPLE.COM", + "Creation Date: 1997-09-15T04:00:00Z", + "Registry Expiry Date: 2028-09-14T04:00:00Z", + "Updated Date: 2024-08-14T07:01:34Z", + "Domain Status: clientTransferProhibited", + ].join("\n"), + ); + expect(out.registered).toBe("1997-09-15T04:00:00Z"); + expect(out.expires).toBe("2028-09-14T04:00:00Z"); + expect(out.updated).toBe("2024-08-14T07:01:34Z"); + expect(out.status).toEqual(["clientTransferProhibited"]); + }); + + it("handles a DENIC-style response (status + change, no expiry)", () => { + const out = parseWhoisText(["Status: connect", "Changed: 2023-02-11T10:00:00+01:00", "Nserver: ns.example.de"].join("\n")); + expect(out.status).toEqual(["connect"]); + expect(out.updated).toBe("2023-02-11T10:00:00+01:00"); + expect(out.expires).toBeNull(); + expect(out.registered).toBeNull(); + }); +}); diff --git a/cloudflare-worker/tsconfig.json b/cloudflare-worker/tsconfig.json new file mode 100644 index 0000000..c59e841 --- /dev/null +++ b/cloudflare-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "lib": ["es2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/cloudflare-worker/vitest.config.ts b/cloudflare-worker/vitest.config.ts new file mode 100644 index 0000000..d9ebefd --- /dev/null +++ b/cloudflare-worker/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000..347050c --- /dev/null +++ b/cloudflare-worker/wrangler.toml @@ -0,0 +1,38 @@ +name = "inwx-bot" +main = "src/index.ts" +compatibility_date = "2024-09-23" + +# Scheduled triggers. The availability check and the WHOIS refresh run in +# separate invocations so each gets its own subrequest budget (see index.ts). +# Keep these in sync with RUN_CRON / WHOIS_CRON in src/index.ts. +# https://developers.cloudflare.com/workers/configuration/cron-triggers/ +[triggers] +crons = ["0 6 * * *", "30 6 * * *"] + +# KV namespace used for the domain list and the run results. +# Create your own with: wrangler kv namespace create INWX_BOT +# and replace the id below (the id is an identifier, not a secret). +[[kv_namespaces]] +binding = "INWX_BOT" +id = "4a136c11e00c4e02b19206117ff820f8" + +# Non-secret configuration. Secrets (credentials, tokens) are set with +# `wrangler secret put` ??? see README.md. +[vars] +# Delay between INWX API calls in milliseconds (rate limiting). +API_DELAY_MS = "1000" +# SAFETY: while "true" the bot only reports available domains and never buys. +# Set to "false" (and redeploy) to actually register free domains. +DRY_RUN = "true" +# Expiry-alert thresholds in days (comma-separated) for the WHOIS table. +EXPIRY_ALERT_DAYS = "30,14,7,1" +# Registration options applied to domain.create (defaults shown; overridable in +# the dashboard settings). RENEWAL_MODE: AUTORENEW | AUTODELETE | AUTOEXPIRE. +RENEWAL_MODE = "AUTORENEW" +TRANSFER_LOCK = "true" +# PERIOD = "1Y" # registration period; empty uses the INWX minimum +# Opt-in port-43 WHOIS fallback for ccTLDs without RDAP dates (e.g. .de). Off by +# default — many registries block datacenter IPs, so it often yields nothing. +WHOIS_PORT43 = "false" +# Uncomment to test against the INWX OT&E sandbox instead of production: +# INWX_API_URL = "https://api.ote.domrobot.com/jsonrpc/" diff --git a/domains.txt b/domains.txt.sample similarity index 100% rename from domains.txt rename to domains.txt.sample diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7bd88a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/readme.md b/readme.md index af19e15..75d9134 100644 --- a/readme.md +++ b/readme.md @@ -1,15 +1,62 @@ +This project is a bot for [INWX.de](https://github.com/inwx) that checks the availability of domains. If a domain is available, the bot automatically registers it through the INWX API. + +There are two ways to run it: + +- **CLI script** (this document): a Python script you run on demand or via cron. +- **Cloudflare Worker** (serverless, scheduled): see + [`cloudflare-worker/`](cloudflare-worker/README.md). It runs on a Cron + Trigger, stores the domain list and results in Workers KV, and needs no + server of your own. + ## install dependencies +``` pip install -r requirements.txt +``` ## change access data -fill in your access data into .env.sample and rename it to .env +Fill in your access data into `.env.sample` and rename it to `.env`. -Most of the required data to buy a domain will be fetched automaticaly. -If you want to use custom parameter fill in the data in the .env and pass it to the buy function. +Most of the required data to buy a domain will be fetched automatically. +If you want to use custom parameters fill in the data in the `.env` and pass it to the buy function. + +## prepare domain list +Copy `domains.txt.sample` to `domains.txt` and add one domain per line: +``` +cp domains.txt.sample domains.txt +``` ## start script +``` +python check_domain.py +``` + +Output: live status per domain on stdout, a summary table, and a CSV +report (`domain_status.csv` by default). Detailed logs are written to +`log.txt`. + +## configuration + +The following environment variables are recognised (all optional except +`username` / `password`): + +| Variable | Default | Description | +|---------------------|----------------------|------------------------------------------| +| `username` | — | INWX login | +| `password` | — | INWX password | +| `ns1`, `ns2` | — | Optional nameservers used on purchase | +| `domains_file` | `domains.txt` | Path to the input domain list | +| `csv_file` | `domain_status.csv` | Path to the result CSV | +| `log_file` | `log.txt` | Path to the log file | +| `api_delay_seconds` | `1.0` | Delay between API calls (rate limiting) | + +## development + +Install dev dependencies and run the tests / linter: +``` +pip install -r requirements-dev.txt +pytest +ruff check . +``` -## todo -- [ ] Implement the 2fa -- [ ] Status/Mail when a registration was successful +## todo - [ ] Implement the optional arguments for domain registration diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..795bb0a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +ruff>=0.6 diff --git a/requirements.txt b/requirements.txt index b06094c..b5dec0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -certifi==2022.9.24 -charset-normalizer==2.1.1 -idna==3.4 -inwx-domrobot==3.1.0 -python-dotenv==0.21.0 -requests==2.28.1 -urllib3==1.26.13 +certifi>=2024.7.4 +charset-normalizer>=3.3.2 +idna>=3.7 +inwx-domrobot>=3.2.0 +python-dotenv>=1.0.1 +requests>=2.32.3 +urllib3>=2.2.2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_check_domain.py b/tests/test_check_domain.py new file mode 100644 index 0000000..ef62c1f --- /dev/null +++ b/tests/test_check_domain.py @@ -0,0 +1,97 @@ +import csv +import sys +import types +from pathlib import Path + +import pytest + +# Stub the INWX SDK so that importing check_domain works without the real +# package being installed (it's a network-only dependency). +inwx_module = types.ModuleType("INWX") +domrobot_module = types.ModuleType("INWX.Domrobot") + + +class _StubApiClient: + API_LIVE_URL = "https://example.invalid/" + + def __init__(self, *args, **kwargs): + pass + + +domrobot_module.ApiClient = _StubApiClient +sys.modules.setdefault("INWX", inwx_module) +sys.modules.setdefault("INWX.Domrobot", domrobot_module) + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import check_domain # noqa: E402 + + +def test_load_domains_strips_blank_lines(tmp_path): + path = tmp_path / "domains.txt" + path.write_text("foo.de\n\n bar.de \n\nbaz.de\n", encoding="utf-8") + assert check_domain.load_domains(path) == ["foo.de", "bar.de", "baz.de"] + + +def test_load_domains_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + check_domain.load_domains(tmp_path / "missing.txt") + + +def test_write_csv_roundtrip(tmp_path): + csv_path = tmp_path / "out.csv" + statuses = [ + { + "domain": "foo.de", + "available": True, + "action": "purchased", + "detail": "success", + "api_code": 1000, + "api_msg": "ok", + }, + { + "domain": "bar.de", + "available": False, + "action": "skipped", + "detail": "already registered", + "api_code": 1000, + "api_msg": "domain not available", + }, + ] + check_domain.write_csv(csv_path, statuses) + + with csv_path.open(encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + assert [r["domain"] for r in rows] == ["foo.de", "bar.de"] + assert rows[0]["action"] == "purchased" + assert rows[1]["available"] == "False" + + +def test_inwx_api_error_message(): + err = check_domain.InwxApiError(2303, "Object does not exist", "during domain check") + assert err.code == 2303 + assert "2303" in str(err) + assert "during domain check" in str(err) + + +def test_call_raises_on_non_success(monkeypatch): + monkeypatch.setattr( + check_domain.api_client, + "call_api", + lambda action, params: {"code": 2400, "msg": "boom"}, + raising=False, + ) + with pytest.raises(check_domain.InwxApiError) as info: + check_domain._call("domain.check", {"domain": "x.de"}, "ctx") + assert info.value.code == 2400 + + +def test_call_returns_result_on_success(monkeypatch): + payload = {"code": 1000, "resData": {"ok": True}} + monkeypatch.setattr( + check_domain.api_client, + "call_api", + lambda action, params: payload, + raising=False, + ) + assert check_domain._call("account.info") is payload