From 396548c2d55de98c99a7910b24c6b49333183dfc Mon Sep 17 00:00:00 2001 From: Kolja <37582741+koljasagorski@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:38:52 +0100 Subject: [PATCH 01/15] Update readme.md --- readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 44abeec..e79b67b 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,5 @@ +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. + ## install dependencies pip install -r requirements.txt @@ -10,4 +12,4 @@ If you want to use custom parameter fill in the data in the .env and pass it to ## start script ## todo -- [ ] Implement the optional arguments for domain registration \ No newline at end of file +- [ ] Implement the optional arguments for domain registration From d6cac143ddd6a32266f4ffdf4093657bff6d6767 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 16:58:42 +0000 Subject: [PATCH 02/15] Refactor INWX bot for robustness, configurability, and test coverage - Wrap api_client.logout() in try/except inside a finally block so failures don't mask earlier errors and don't leak unhandled exceptions - Add InwxApiError exception and a _call() helper to deduplicate the repeated `code == 1000` API-response checks - Make file paths (domains list, CSV output, log file) and the per-call API delay configurable via environment variables; rate-limit between domain checks to avoid hammering the INWX API - Validate that the domains file exists before iterating - Unify logging: file handler + warning-level console handler, set up inside main() so importing the module has no side effects - Move logging.basicConfig out of module scope (was creating log.txt on import) and extract process_domain / print_summary helpers from main() - Bump dependencies to current versions (requests 2.32, urllib3 2.2, python-dotenv 1.0, certifi 2024.7, idna 3.7, etc.) - Move domains.txt to domains.txt.sample and gitignore the real file + generated artefacts (CSV, pycache, ruff/pytest caches) - Add pytest suite (load_domains, write_csv, _call success/failure, InwxApiError) with a stubbed INWX SDK so tests run without network - Add ruff + pytest configuration in pyproject.toml and a GitHub Actions CI workflow running on Python 3.10/3.11/3.12 - Flesh out README: start command, configuration table, dev setup https://claude.ai/code/session_01KJj6SRqa7UBbtCpReVjaeB --- .github/workflows/ci.yml | 22 +++ .gitignore | 8 +- check_domain.py | 311 +++++++++++++++++------------- domains.txt => domains.txt.sample | 0 pyproject.toml | 10 + readme.md | 47 ++++- requirements-dev.txt | 3 + requirements.txt | 14 +- tests/__init__.py | 0 tests/test_check_domain.py | 97 ++++++++++ 10 files changed, 367 insertions(+), 145 deletions(-) create mode 100644 .github/workflows/ci.yml rename domains.txt => domains.txt.sample (100%) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_check_domain.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cb2fdda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +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 diff --git a/.gitignore b/.gitignore index d3c46f1..b71e2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ venv/ .env log.txt -.vscode \ No newline at end of file +.vscode +domains.txt +domain_status.csv +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ 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/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 e79b67b..a9165fc 100644 --- a/readme.md +++ b/readme.md @@ -1,15 +1,54 @@ 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. ## 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 +## 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 From f41123f044b977c5f0ea9e73f20793c57335baba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 11:54:42 +0000 Subject: [PATCH 03/15] Add Cloudflare Worker deployment (scheduled, serverless) Adds a TypeScript Cloudflare Worker variant of the INWX domain bot under cloudflare-worker/, alongside the existing Python CLI script. - Talks to the INWX JSON-RPC API directly via fetch (the requests-based Python SDK does not run on Workers); session cookie handled manually. - Runs on a Cron Trigger; domain list and results stored in Workers KV. - DRY_RUN defaults to "true" so it never registers domains until enabled. - Token-protected HTTP endpoints to manage the list, trigger runs, and download JSON/CSV results. - Optional 2FA (mobile TAN) via Web Crypto TOTP, verified against the RFC 6238 test vectors. - Optional Slack/Discord webhook notifications for noteworthy runs. - CI job added to typecheck the Worker; readme updated to describe both deployment options. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- .github/workflows/ci.yml | 15 + cloudflare-worker/.dev.vars.sample | 20 + cloudflare-worker/.gitignore | 5 + cloudflare-worker/README.md | 100 ++ cloudflare-worker/package-lock.json | 1606 +++++++++++++++++++++++++++ cloudflare-worker/package.json | 18 + cloudflare-worker/src/index.ts | 292 +++++ cloudflare-worker/src/inwx.ts | 131 +++ cloudflare-worker/src/totp.ts | 66 ++ cloudflare-worker/tsconfig.json | 15 + cloudflare-worker/wrangler.toml | 26 + readme.md | 8 + 12 files changed, 2302 insertions(+) create mode 100644 cloudflare-worker/.dev.vars.sample create mode 100644 cloudflare-worker/.gitignore create mode 100644 cloudflare-worker/README.md create mode 100644 cloudflare-worker/package-lock.json create mode 100644 cloudflare-worker/package.json create mode 100644 cloudflare-worker/src/index.ts create mode 100644 cloudflare-worker/src/inwx.ts create mode 100644 cloudflare-worker/src/totp.ts create mode 100644 cloudflare-worker/tsconfig.json create mode 100644 cloudflare-worker/wrangler.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb2fdda..ccc5a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,18 @@ jobs: - 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 diff --git a/cloudflare-worker/.dev.vars.sample b/cloudflare-worker/.dev.vars.sample new file mode 100644 index 0000000..057390d --- /dev/null +++ b/cloudflare-worker/.dev.vars.sample @@ -0,0 +1,20 @@ +# 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 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..c87a8f7 --- /dev/null +++ b/cloudflare-worker/README.md @@ -0,0 +1,100 @@ +# 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 + +- A **Cron Trigger** invokes the Worker on a schedule (default: daily 06:00 UTC). +- It logs in to INWX, checks each domain from the KV list with `domain.check`, + and — unless `DRY_RUN` is on — registers free ones with `domain.create`. +- Results are written to KV (`results:latest.json` and `results:latest.csv`). +- Optional: a notification is POSTed to a Slack/Discord-compatible webhook when + something noteworthy happens (a domain is available, bought, or a run fails). + +> **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 (one per line, or a JSON array). +curl -X PUT https://inwx-bot..workers.dev/domains \ + -H "Authorization: Bearer " \ + --data-binary $'example.de\nmy-other-domain.com' +``` + +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. | + +Secrets (set with `wrangler secret put`): `INWX_USERNAME`, `INWX_PASSWORD`, +`ADMIN_TOKEN`, and the optional `INWX_SHARED_SECRET`, `NOTIFY_WEBHOOK_URL`, +`INWX_NS1`, `INWX_NS2`. + +The schedule is controlled by the `crons` array in `wrangler.toml`. + +## HTTP endpoints + +All except `GET /` require `Authorization: Bearer `. + +| Method & path | Description | +|----------------------|----------------------------------------------------------| +| `GET /` | Health/status: last run time, domain count, last error. | +| `GET /domains` | Current domain list. | +| `PUT /domains` | Replace the list (newline-separated text or JSON array). | +| `POST /run` | Run the check now and return the result. | +| `POST /run?async=true` | Start a run in the background, return `202` immediately. | +| `GET /results.json` | Full result of the last run. | +| `GET /results.csv` | Last run as CSV (same columns as the Python script). | + +## Local development + +```bash +cp .dev.vars.sample .dev.vars # fill in credentials (gitignored) +npm run dev # wrangler dev +npm run typecheck # tsc --noEmit +``` + +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..1b6b51e --- /dev/null +++ b/cloudflare-worker/package-lock.json @@ -0,0 +1,1606 @@ +{ + "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", + "wrangler": "^3.80.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "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-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "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.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "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.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "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.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "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-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "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.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "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.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "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.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "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.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "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.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "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.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "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.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "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.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "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.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "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/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "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/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "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", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "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/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "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": "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/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "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", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "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/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/cloudflare-worker/package.json b/cloudflare-worker/package.json new file mode 100644 index 0000000..0d8d5c0 --- /dev/null +++ b/cloudflare-worker/package.json @@ -0,0 +1,18 @@ +{ + "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" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241011.0", + "typescript": "^5.6.0", + "wrangler": "^3.80.0" + } +} diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts new file mode 100644 index 0000000..fa4ad0b --- /dev/null +++ b/cloudflare-worker/src/index.ts @@ -0,0 +1,292 @@ +/** + * 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. + * - HTTP endpoints (bearer-token protected) let you manage the list, + * trigger a run, and download results. + * + * This mirrors the behaviour of the Python `check_domain.py` script. + */ +import { InwxClient, type AccountInfo } from "./inwx"; + +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; + API_DELAY_MS?: string; + DRY_RUN?: 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; +} + +interface RunRecord { + timestamp: string; + dryRun: boolean; + statuses: DomainStatus[]; + error?: 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 sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const isTrue = (value: string | undefined) => (value ?? "").trim().toLowerCase() === "true"; + +function parseDomainList(raw: string): string[] { + const trimmed = raw.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return parsed.map((d) => String(d).trim()).filter(Boolean); + } + } catch { + // fall through to newline parsing + } + } + return trimmed + .split(/\r?\n/) + .map((d) => d.trim()) + .filter(Boolean); +} + +async function loadDomains(env: Env): Promise { + const raw = await env.INWX_BOT.get(DOMAINS_KEY); + return raw ? parseDomainList(raw) : []; +} + +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"); +} + +async function processDomain( + client: InwxClient, + domain: string, + accountInfo: AccountInfo, + ns: string[], + dryRun: boolean, +): Promise { + const available = await client.isDomainFree(domain); + if (!available) { + return { + domain, + available: false, + action: "skipped", + detail: "already registered", + api_code: 1000, + api_msg: "domain not available", + }; + } + if (dryRun) { + return { domain, available: true, action: "would_purchase", detail: "dry run – not purchased", api_code: null, api_msg: null }; + } + + const buyParams: Record = { + domain, + registrant: accountInfo.defaultRegistrant, + admin: accountInfo.defaultAdmin, + tech: accountInfo.defaultTech, + billing: accountInfo.defaultBilling, + }; + if (ns.length > 0) buyParams.ns = ns; + + const { success, code, msg } = await client.buyDomain(buyParams); + if (success) { + return { domain, available: true, action: "purchased", detail: "success", api_code: code ?? null, api_msg: msg }; + } + return { domain, available: true, action: "purchase_failed", detail: `Code ${code}: ${msg}`, api_code: code ?? null, api_msg: msg }; +} + +async function runCheck(env: Env): Promise { + if (!env.INWX_USERNAME || !env.INWX_PASSWORD) { + throw new Error("INWX_USERNAME or INWX_PASSWORD is not configured"); + } + const dryRun = isTrue(env.DRY_RUN); + const delayMs = env.API_DELAY_MS ? Number(env.API_DELAY_MS) : 1000; + 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 domains = await loadDomains(env); + + for (let i = 0; i < domains.length; i++) { + if (i > 0 && delayMs > 0) await sleep(delayMs); + const domain = domains[i]; + try { + const status = await processDomain(client, domain, accountInfo, ns, dryRun); + statuses.push(status); + console.log(`[${i + 1}/${domains.length}] ${domain} -> ${status.action}`); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + statuses.push({ domain, available: null, action: "error", detail, api_code: null, api_msg: null }); + console.error(`[${i + 1}/${domains.length}] ${domain} -> error: ${detail}`); + } + } + } finally { + if (loggedIn) { + await client.logout().catch(() => {}); + } + } + return statuses; +} + +function summarize(record: RunRecord): string { + if (record.error) return `INWX-Bot: Lauf fehlgeschlagen – ${record.error}`; + const counts: Record = {}; + for (const s of record.statuses) counts[s.action] = (counts[s.action] ?? 0) + 1; + const parts = Object.entries(counts).map(([k, v]) => `${k}: ${v}`); + const prefix = record.dryRun ? "INWX-Bot (Probelauf)" : "INWX-Bot"; + return `${prefix}: ${record.statuses.length} Domains geprüft – ${parts.join(", ") || "keine"}`; +} + +async function notify(env: Env, record: RunRecord): Promise { + if (!env.NOTIFY_WEBHOOK_URL) return; + // Only ping when something is worth knowing (available / bought / failed / errored). + const noteworthy = Boolean(record.error) || record.statuses.some((s) => s.action !== "skipped"); + if (!noteworthy) return; + + const text = summarize(record); + const interesting = record.statuses.filter((s) => s.action !== "skipped"); + 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, statuses: interesting }), + }); + } catch { + // Notifications are best-effort. + } +} + +async function runAndStore(env: Env): Promise { + const timestamp = new Date().toISOString(); + const dryRun = isTrue(env.DRY_RUN); + let record: RunRecord; + try { + record = { timestamp, dryRun, statuses: await runCheck(env) }; + } 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)); + await notify(env, record); + return record; +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +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(/\/+$/, "") || "/"; + + // Public, low-detail health endpoint (no domain details leaked). + if (request.method === "GET" && path === "/") { + const raw = await env.INWX_BOT.get(RESULTS_JSON_KEY); + const last = raw ? (JSON.parse(raw) as RunRecord) : null; + return json({ + ok: true, + service: "inwx-bot worker", + lastRun: last?.timestamp ?? null, + lastRunDomains: last?.statuses.length ?? 0, + lastRunError: last?.error ?? null, + }); + } + + // Everything below requires the admin bearer token. + if (!isAuthorized(request, env)) return unauthorized(); + + if (request.method === "GET" && path === "/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 === "/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 === "/domains") { + if (request.method === "GET") { + return json({ domains: await loadDomains(env) }); + } + if (request.method === "PUT" || request.method === "POST") { + const domains = parseDomainList(await request.text()); + await env.INWX_BOT.put(DOMAINS_KEY, JSON.stringify(domains)); + return json({ ok: true, count: domains.length, domains }); + } + } + + if (request.method === "POST" && path === "/run") { + // `?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(runAndStore(env).then(() => undefined)); + return json({ ok: true, started: true }, 202); + } + const record = await runAndStore(env); + return json({ ok: !record.error, ...record }); + } + + return json({ error: "not found" }, 404); +} + +export default { + async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(runAndStore(env).then(() => undefined)); + }, + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + return handleFetch(request, env, ctx); + }, +} satisfies ExportedHandler; diff --git a/cloudflare-worker/src/inwx.ts b/cloudflare-worker/src/inwx.ts new file mode 100644 index 0000000..c7e207b --- /dev/null +++ b/cloudflare-worker/src/inwx.ts @@ -0,0 +1,131 @@ +/** + * 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; + } + } + + async isDomainFree(domain: string): Promise { + const result = await this.call("domain.check", { domain }, "during domain check"); + const data = result.resData as { domain?: Array<{ avail?: number | boolean }> } | undefined; + return Boolean(data?.domain?.[0]?.avail); + } + + async getAccountInfo(): Promise { + const result = await this.call("account.info", {}, "while fetching account info"); + return (result.resData ?? {}) as AccountInfo; + } + + /** 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/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/wrangler.toml b/cloudflare-worker/wrangler.toml new file mode 100644 index 0000000..603263f --- /dev/null +++ b/cloudflare-worker/wrangler.toml @@ -0,0 +1,26 @@ +name = "inwx-bot" +main = "src/index.ts" +compatibility_date = "2024-09-23" + +# Runs the domain check on a schedule. Default: every day at 06:00 UTC. +# Adjust or add entries — https://developers.cloudflare.com/workers/configuration/cron-triggers/ +[triggers] +crons = ["0 6 * * *"] + +# KV namespace used for the domain list and the run results. +# Create it once with: wrangler kv namespace create INWX_BOT +# then paste the returned id below. +[[kv_namespaces]] +binding = "INWX_BOT" +id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" + +# 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" +# Uncomment to test against the INWX OT&E sandbox instead of production: +# INWX_API_URL = "https://api.ote.domrobot.com/jsonrpc/" diff --git a/readme.md b/readme.md index a9165fc..75d9134 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,13 @@ 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 From 66e03ad75c3bafe385c80266fc0e23731db2dae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:06:37 +0000 Subject: [PATCH 04/15] Add web dashboard to the Cloudflare Worker Serves a self-contained dashboard at `/` that shows run status, per-domain results, lets you edit the domain list, trigger a check, and download the CSV. Data endpoints are reorganised under `/api/*`: - GET / -> dashboard HTML (strict nonce-based CSP, no external assets) - GET /api/status -> public status (dry-run mode, last run, last error) - GET/PUT /api/domains -> read / replace the domain list (token protected) - POST /api/run -> trigger a run (token protected; ?async=true supported) - GET /api/results.json|.csv -> last run results (token protected) The dashboard prompts for ADMIN_TOKEN (kept in sessionStorage) and renders all external data via safe DOM APIs (no innerHTML) to avoid XSS. README updated. Verified: tsc typecheck, client-script syntax check, and end-to-end requests against `wrangler dev` (HTML + CSP header, status, 401 without token, domain save/list, run error path, CSV, 404). https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/README.md | 44 ++-- cloudflare-worker/src/dashboard.ts | 316 +++++++++++++++++++++++++++++ cloudflare-worker/src/index.ts | 35 +++- 3 files changed, 374 insertions(+), 21 deletions(-) create mode 100644 cloudflare-worker/src/dashboard.ts diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index c87a8f7..7744425 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -11,6 +11,8 @@ Workers), and stores the domain list and results in **Workers KV**. - It logs in to INWX, checks each domain from the KV list with `domain.check`, and — unless `DRY_RUN` is on — registers free ones with `domain.create`. - Results are written to KV (`results:latest.json` and `results:latest.csv`). +- A **web dashboard** is served at `/` to view status, edit the domain list, + trigger a run, and download the CSV. - Optional: a notification is POSTed to a Slack/Discord-compatible webhook when something noteworthy happens (a domain is available, bought, or a run fails). @@ -46,8 +48,9 @@ npx wrangler secret put INWX_NS2 # 3. Deploy. npx wrangler deploy -# 4. Seed the domain list (one per line, or a JSON array). -curl -X PUT https://inwx-bot..workers.dev/domains \ +# 4. Seed the domain list — either in the dashboard at +# https://inwx-bot..workers.dev/ or via the API: +curl -X PUT https://inwx-bot..workers.dev/api/domains \ -H "Authorization: Bearer " \ --data-binary $'example.de\nmy-other-domain.com' ``` @@ -74,19 +77,34 @@ Secrets (set with `wrangler secret put`): `INWX_USERNAME`, `INWX_PASSWORD`, The schedule is controlled by the `crons` array in `wrangler.toml`. +## 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, +- edit and save the domain list, +- trigger a check immediately ("Jetzt prüfen"), +- download the results as CSV. + +A strict Content-Security-Policy (nonce-based, no external assets) is applied. + ## HTTP endpoints -All except `GET /` require `Authorization: Bearer `. - -| Method & path | Description | -|----------------------|----------------------------------------------------------| -| `GET /` | Health/status: last run time, domain count, last error. | -| `GET /domains` | Current domain list. | -| `PUT /domains` | Replace the list (newline-separated text or JSON array). | -| `POST /run` | Run the check now and return the result. | -| `POST /run?async=true` | Start a run in the background, return `202` immediately. | -| `GET /results.json` | Full result of the last run. | -| `GET /results.csv` | Last run as CSV (same columns as the Python script). | +`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. | +| `PUT /api/domains` | Replace the list (newline-separated text or JSON array). | +| `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. | +| `GET /api/results.json` | Full result of the last run. | +| `GET /api/results.csv` | Last run as CSV (same columns as the Python script). | ## Local development diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts new file mode 100644 index 0000000..c14c514 --- /dev/null +++ b/cloudflare-worker/src/dashboard.ts @@ -0,0 +1,316 @@ +/** + * 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 index fa4ad0b..6de6632 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -3,11 +3,14 @@ * * - A Cron Trigger runs the check on a schedule (see wrangler.toml). * - The domain list and the latest results live in a KV namespace. - * - HTTP endpoints (bearer-token protected) let you manage the list, - * trigger a run, and download results. + * - 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"; export interface Env { @@ -231,13 +234,29 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P const url = new URL(request.url); const path = url.pathname.replace(/\/+$/, "") || "/"; - // Public, low-detail health endpoint (no domain details leaked). - if (request.method === "GET" && path === "/") { + // 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'; connect-src 'self'; ` + + `img-src data:; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'`, + "cache-control": "no-store", + }, + }); + } + + // Public, low-detail status endpoint (no domain details leaked). + if (request.method === "GET" && path === "/api/status") { const raw = await env.INWX_BOT.get(RESULTS_JSON_KEY); const last = raw ? (JSON.parse(raw) as RunRecord) : null; return json({ ok: true, service: "inwx-bot worker", + dryRun: isTrue(env.DRY_RUN), + authConfigured: Boolean(env.ADMIN_TOKEN), lastRun: last?.timestamp ?? null, lastRunDomains: last?.statuses.length ?? 0, lastRunError: last?.error ?? null, @@ -247,17 +266,17 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P // Everything below requires the admin bearer token. if (!isAuthorized(request, env)) return unauthorized(); - if (request.method === "GET" && path === "/results.csv") { + 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 === "/results.json") { + 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 === "/domains") { + if (path === "/api/domains") { if (request.method === "GET") { return json({ domains: await loadDomains(env) }); } @@ -268,7 +287,7 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P } } - if (request.method === "POST" && path === "/run") { + if (request.method === "POST" && path === "/api/run") { // `?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") { From 794065ddb0b443d5fc75ebd1f5602c00606bb3a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:34:14 +0000 Subject: [PATCH 05/15] Add WHOIS / domain-status table to the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For every watched domain the bot now gathers registration metadata (registered / expires / last changed / status / registrar) and shows it in a new dashboard table. Expiry within 30 days is highlighted. Data sources (per domain, best first): - INWX `domain.info` for domains in the account — authoritative dates for any TLD (including .de). - RDAP (the JSON successor to WHOIS) for everything else. An explicit User-Agent is sent because some RDAP servers reject UA-less requests (403). Unregistered domains (RDAP 404) are reported as available. New endpoints (token protected): `GET /api/whois` and `POST /api/whois/refresh` (with `?async=true`). The data is stored in KV (`whois:latest.json`), refreshed on each cron run and via a dashboard button. `GET /api/status` now also reports `whoisLastRun`. Verified: tsc, client-script syntax/structure checks, and end-to-end against `wrangler dev` — RDAP returns real registration/expiry/updated data for a registered domain and flags an unregistered one as available. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/README.md | 20 ++++- cloudflare-worker/src/dashboard.ts | 86 ++++++++++++++++++++- cloudflare-worker/src/index.ts | 117 ++++++++++++++++++++++++++++- cloudflare-worker/src/inwx.ts | 10 +++ cloudflare-worker/src/whois.ts | 117 +++++++++++++++++++++++++++++ 5 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 cloudflare-worker/src/whois.ts diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 7744425..e8d49f2 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -11,8 +11,10 @@ Workers), and stores the domain list and results in **Workers KV**. - It logs in to INWX, checks each domain from the KV list with `domain.check`, and — unless `DRY_RUN` is on — registers free ones with `domain.create`. - 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`). - A **web dashboard** is served at `/` to view status, edit the domain list, - trigger a run, and download the CSV. + trigger a run, view the WHOIS table, and download the CSV. - Optional: a notification is POSTed to a Slack/Discord-compatible webhook when something noteworthy happens (a domain is available, bought, or a run fails). @@ -86,10 +88,24 @@ enter your `ADMIN_TOKEN` once (kept in the browser's `sessionStorage`) to - see the last run's status and per-domain results, - edit and save the domain list, - trigger a check immediately ("Jetzt prüfen"), +- view the **WHOIS / domain-status table** (registered / expires / last changed / + status per domain; expiry within 30 days is highlighted) and refresh it, - download the results as CSV. A strict Content-Security-Policy (nonce-based, no external assets) is applied. +### 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. Note that some + ccTLDs — notably **.de** (DENIC) — do not publish registration/expiry over + RDAP, so those columns may stay empty for domains you don't own. + +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 @@ -105,6 +121,8 @@ A strict Content-Security-Policy (nonce-based, no external assets) is applied. | `POST /api/run?async=true`| Start a run in the background, return `202` immediately. | | `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). | ## Local development diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts index c14c514..66a8f2b 100644 --- a/cloudflare-worker/src/dashboard.ts +++ b/cloudflare-worker/src/dashboard.ts @@ -48,6 +48,8 @@ th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;le .table-wrap{overflow-x:auto} .muted{color:var(--muted);font-size:13px} .error{color:var(--err);font-size:13px} +.warn{color:#b45309;font-weight:600} +.danger{color:var(--err);font-weight:600} .badge{display:inline-block;padding:3px 8px;border-radius:999px;font-size:12px;font-weight:600} .badge-skipped{background:var(--chip);color:var(--muted)} .badge-would_purchase{background:#dbeafe;color:#1e40af} @@ -119,6 +121,23 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo + +
+
+

WHOIS / Domain-Status

+
+ + +
+
+
+ + + +
DomainStatusRegistriertLäuft abZuletzt geändertRegistrarQuelle
+
+ +
INWX Bot · Cron-gesteuerter Domain-Check auf Cloudflare Workers
@@ -132,6 +151,8 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo function clearNode(node) { while (node.firstChild) { node.removeChild(node.firstChild); } } function show(node, on) { node.hidden = !on; } function fmtTime(iso) { if (!iso) { return 'noch kein Lauf'; } var d = new Date(iso); return isNaN(d.getTime()) ? iso : d.toLocaleString(); } + function fmtDate(v) { if (!v) { return '–'; } var d = new Date(v); return isNaN(d.getTime()) ? String(v).slice(0, 10) : d.toLocaleDateString(); } + function daysUntil(v) { if (!v) { return null; } var d = new Date(v); if (isNaN(d.getTime())) { return null; } return Math.floor((d.getTime() - Date.now()) / 86400000); } function api(path, opts) { opts = opts || {}; @@ -240,8 +261,47 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo }); } + function renderWhois(rec) { + var tbody = el('whois').querySelector('tbody'); clearNode(tbody); + var list = (rec && rec.domains) || []; + show(el('whois-empty'), list.length === 0); + if (rec && rec.timestamp) { el('whois-time').textContent = 'Stand: ' + fmtTime(rec.timestamp); } + var i; + for (i = 0; i < list.length; i++) { + var w = list[i]; + var tr = document.createElement('tr'); + var cDomain = document.createElement('td'); cDomain.textContent = w.domain; tr.appendChild(cDomain); + var cStatus = document.createElement('td'); + if (w.available === true) { + var b = document.createElement('span'); b.className = 'badge badge-would_purchase'; b.textContent = 'verfügbar'; cStatus.appendChild(b); + } else if (w.error) { + cStatus.textContent = w.error; cStatus.className = 'muted'; + } else { + cStatus.textContent = (w.status && w.status.length) ? w.status.join(', ') : 'registriert'; + } + tr.appendChild(cStatus); + var cReg = document.createElement('td'); cReg.textContent = fmtDate(w.registered); tr.appendChild(cReg); + var cExp = document.createElement('td'); cExp.textContent = fmtDate(w.expires); + var du = daysUntil(w.expires); + if (du !== null && du < 0) { cExp.className = 'danger'; } + else if (du !== null && du <= 30) { cExp.className = 'warn'; } + tr.appendChild(cExp); + var cUpd = document.createElement('td'); cUpd.textContent = fmtDate(w.updated); tr.appendChild(cUpd); + var cRar = document.createElement('td'); cRar.textContent = w.registrar || '–'; tr.appendChild(cRar); + var cSrc = document.createElement('td'); cSrc.textContent = w.source || '–'; cSrc.className = 'muted'; tr.appendChild(cSrc); + tbody.appendChild(tr); + } + } + + function loadWhois() { + return api('/api/whois').then(function (r) { + if (r.status === 401) { throw { auth: true }; } + return r.json(); + }).then(function (rec) { renderWhois(rec); }); + } + function loadAuthed() { - return Promise.all([loadDomains(), loadResults()]).then(function () { + return Promise.all([loadDomains(), loadResults(), loadWhois()]).then(function () { showApp(true); }).catch(function (e) { if (e && e.auth) { @@ -307,6 +367,30 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo }).catch(function () {}); }); + function pollWhois(before, attempt) { + if (attempt > 60) { el('whois-time').textContent = 'WHOIS-Abfrage läuft noch — bitte später aktualisieren.'; el('whois-refresh').disabled = false; return; } + setTimeout(function () { + api('/api/whois').then(function (r) { return r.json(); }).then(function (rec) { + var ts = (rec && rec.timestamp) ? rec.timestamp : null; + if (ts && ts !== before) { + renderWhois(rec); el('whois-refresh').disabled = false; + } else { + pollWhois(before, attempt + 1); + } + }).catch(function () { pollWhois(before, attempt + 1); }); + }, 2500); + } + + el('whois-refresh').addEventListener('click', function () { + var btn = this; btn.disabled = true; + var before = null; + api('/api/whois').then(function (r) { return r.json(); }).then(function (rec) { before = (rec && rec.timestamp) ? rec.timestamp : null; }) + .then(function () { return api('/api/whois/refresh?async=true', { method: 'POST' }); }) + .then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); }) + .then(function () { el('whois-time').textContent = 'WHOIS-Abfrage läuft…'; pollWhois(before, 0); }) + .catch(function (e) { el('whois-time').textContent = (e && e.auth) ? 'Nicht autorisiert.' : 'Fehler beim Start.'; btn.disabled = false; }); + }); + loadPublicStatus(); if (getToken()) { loadAuthed(); } else { showApp(false); } })(); diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 6de6632..9e34422 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -12,6 +12,7 @@ */ import { dashboardPage } from "./dashboard"; import { InwxClient, type AccountInfo } from "./inwx"; +import { lookupRdap, type WhoisInfo } from "./whois"; export interface Env { INWX_BOT: KVNamespace; @@ -45,10 +46,16 @@ interface RunRecord { error?: string; } +interface WhoisRecord { + timestamp: string; + domains: WhoisInfo[]; +} + 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 sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const isTrue = (value: string | undefined) => (value ?? "").trim().toLowerCase() === "true"; @@ -210,6 +217,86 @@ async function runAndStore(env: Env): Promise { 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. */ +async function enrichDomain(client: InwxClient | null, domain: string): 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 lookupRdap(domain); +} + +async function refreshWhois(env: Env): Promise { + const timestamp = new Date().toISOString(); + const delayMs = env.API_DELAY_MS ? Number(env.API_DELAY_MS) : 1000; + 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])); + } 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)); + return record; +} + function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data, null, 2), { status, @@ -250,8 +337,12 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P // Public, low-detail status endpoint (no domain details leaked). if (request.method === "GET" && path === "/api/status") { - const raw = await env.INWX_BOT.get(RESULTS_JSON_KEY); + const [raw, whoisRaw] = await Promise.all([ + env.INWX_BOT.get(RESULTS_JSON_KEY), + env.INWX_BOT.get(WHOIS_KEY), + ]); 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", @@ -260,6 +351,7 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P lastRun: last?.timestamp ?? null, lastRunDomains: last?.statuses.length ?? 0, lastRunError: last?.error ?? null, + whoisLastRun: whois?.timestamp ?? null, }); } @@ -298,12 +390,33 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P return json({ ok: !record.error, ...record }); } + 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") { + if (url.searchParams.get("async") === "true") { + ctx.waitUntil(refreshWhois(env).then(() => undefined)); + return json({ ok: true, started: true }, 202); + } + const record = await refreshWhois(env); + return json({ ok: true, ...record }); + } + return json({ error: "not found" }, 404); } export default { async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): Promise { - ctx.waitUntil(runAndStore(env).then(() => undefined)); + // Run the availability check, then refresh WHOIS data (sequentially, so we + // never hold two INWX sessions at once). + ctx.waitUntil( + (async () => { + await runAndStore(env); + await refreshWhois(env); + })(), + ); }, async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { return handleFetch(request, env, ctx); diff --git a/cloudflare-worker/src/inwx.ts b/cloudflare-worker/src/inwx.ts index c7e207b..b969202 100644 --- a/cloudflare-worker/src/inwx.ts +++ b/cloudflare-worker/src/inwx.ts @@ -123,6 +123,16 @@ export class InwxClient { 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); diff --git a/cloudflare-worker/src/whois.ts b/cloudflare-worker/src/whois.ts new file mode 100644 index 0000000..18cb2fc --- /dev/null +++ b/cloudflare-worker/src/whois.ts @@ -0,0 +1,117 @@ +/** + * 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" | "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; +} + +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; +} + +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) }; + } +} From 5182bb228b056fcc6fb7f0c0ced8513cf37b03db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:38:47 +0000 Subject: [PATCH 06/15] Set KV namespace id so Cloudflare Git deploys succeed The committed wrangler.toml still carried the REPLACE_WITH_YOUR_KV_NAMESPACE_ID placeholder, which made the Cloudflare Workers Build (Git integration) fail with "KV namespace ... is not valid". Pin the real namespace id (an identifier, not a secret) so automatic deploys work. Actual secrets stay out of the repo. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/wrangler.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index 603263f..a8f9200 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -8,11 +8,11 @@ compatibility_date = "2024-09-23" crons = ["0 6 * * *"] # KV namespace used for the domain list and the run results. -# Create it once with: wrangler kv namespace create INWX_BOT -# then paste the returned id below. +# 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 = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" +id = "4a136c11e00c4e02b19206117ff820f8" # Non-secret configuration. Secrets (credentials, tokens) are set with # `wrangler secret put` — see README.md. From b6918061e68df7d3bab300b54e915161b1010abc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 13:19:53 +0000 Subject: [PATCH 07/15] Add run history, change-based alerts, expiry alerts, run lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monitoring smarts for the Worker (KV-based, no new infrastructure): - Run history: each run is appended to a capped list (history:runs, last 50) and shown in a new dashboard "Verlauf" panel. New GET /api/history endpoint. - Per-domain state (state:domains) tracks the last action so notifications fire only when a domain's status actually CHANGES (newly available / bought / failed), instead of on every run. Fatal run errors are de-duplicated too. - Expiry alerts: as owned/registered domains approach renewal, a webhook alert is sent once per crossed threshold (EXPIRY_ALERT_DAYS, default 30,14,7,1), reset when the expiry date changes. - Best-effort KV lock prevents a manual run/whois-refresh from overlapping the scheduled run; /api/run and /api/whois/refresh return 409 when busy. Verified: tsc; unit tests of change-detection / threshold parsing / expiry crossing; and end-to-end against `wrangler dev` with a webhook capture — confirmed error-notify dedup (1 alert for 2 failing runs), an expiry alert firing once and de-duplicating on repeat, and history growing correctly. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/README.md | 22 ++- cloudflare-worker/src/dashboard.ts | 53 +++++- cloudflare-worker/src/index.ts | 250 +++++++++++++++++++++++++---- cloudflare-worker/wrangler.toml | 2 + 4 files changed, 285 insertions(+), 42 deletions(-) diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index e8d49f2..bb91107 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -13,10 +13,15 @@ Workers), and stores the domain list and results in **Workers KV**. - 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 download the CSV. -- Optional: a notification is POSTed to a Slack/Discord-compatible webhook when - something noteworthy happens (a domain is available, bought, or a run fails). + trigger a run, view the WHOIS table and the run history, and download the CSV. +- Optional notifications via a Slack/Discord-compatible webhook — 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. > **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"` @@ -69,9 +74,10 @@ 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. | +| `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. | Secrets (set with `wrangler secret put`): `INWX_USERNAME`, `INWX_PASSWORD`, `ADMIN_TOKEN`, and the optional `INWX_SHARED_SECRET`, `NOTIFY_WEBHOOK_URL`, @@ -123,6 +129,10 @@ The table refreshes on each cron run and via the "WHOIS aktualisieren" button. | `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). | + +`POST /api/run` and `POST /api/whois/refresh` return `409` if another run is +already in progress (best-effort KV lock). ## Local development diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts index 66a8f2b..52cb1ee 100644 --- a/cloudflare-worker/src/dashboard.ts +++ b/cloudflare-worker/src/dashboard.ts @@ -138,6 +138,20 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo + +
+
+

Verlauf

+ letzte Läufe +
+
+ + + +
ZeitpunktGeprüftverfügbargekauftfehlgeschlagenFehler
+
+ +
INWX Bot · Cron-gesteuerter Domain-Check auf Cloudflare Workers
@@ -300,8 +314,41 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo }).then(function (rec) { renderWhois(rec); }); } + function addCell(tr, text, cls) { + var td = document.createElement('td'); + td.textContent = text; + if (cls) { td.className = cls; } + tr.appendChild(td); + } + + function renderHistory(list) { + var tbody = el('history').querySelector('tbody'); clearNode(tbody); + list = list || []; + show(el('history-empty'), list.length === 0); + var i; + for (i = 0; i < list.length; i++) { + var h = list[i]; + var counts = h.counts || {}; + var tr = document.createElement('tr'); + addCell(tr, fmtTime(h.timestamp)); + addCell(tr, String(h.total || 0)); + addCell(tr, String(counts['would_purchase'] || 0)); + addCell(tr, String(counts['purchased'] || 0)); + addCell(tr, String(counts['purchase_failed'] || 0)); + addCell(tr, h.error ? h.error : String(counts['error'] || 0), h.error ? 'error' : ''); + tbody.appendChild(tr); + } + } + + function loadHistory() { + return api('/api/history').then(function (r) { + if (r.status === 401) { throw { auth: true }; } + return r.json(); + }).then(function (list) { renderHistory(list); }); + } + function loadAuthed() { - return Promise.all([loadDomains(), loadResults(), loadWhois()]).then(function () { + return Promise.all([loadDomains(), loadResults(), loadWhois(), loadHistory()]).then(function () { showApp(true); }).catch(function (e) { if (e && e.auth) { @@ -317,7 +364,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo api('/api/results.json').then(function (r) { return r.json(); }).then(function (rec) { var ts = (rec && rec.timestamp) ? rec.timestamp : null; if (ts && ts !== before) { - lastTimestamp = ts; renderStatus(rec); renderResults(rec); + lastTimestamp = ts; renderStatus(rec); renderResults(rec); loadHistory(); el('run-msg').textContent = 'Prüfung abgeschlossen.'; el('run').disabled = false; } else { @@ -334,7 +381,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo }); el('token').addEventListener('keydown', function (e) { if (e.key === 'Enter') { el('connect').click(); } }); el('logout').addEventListener('click', function () { setToken(''); showApp(false); }); - el('refresh').addEventListener('click', function () { loadResults(); }); + el('refresh').addEventListener('click', function () { loadResults(); loadHistory(); }); el('save-domains').addEventListener('click', function () { var btn = this; btn.disabled = true; diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 9e34422..beb7fa0 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -26,6 +26,7 @@ export interface Env { NOTIFY_WEBHOOK_URL?: string; API_DELAY_MS?: string; DRY_RUN?: string; + EXPIRY_ALERT_DAYS?: string; } type Action = "skipped" | "purchased" | "purchase_failed" | "would_purchase" | "error"; @@ -51,11 +52,45 @@ interface WhoisRecord { 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; + 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]; + +/** Actions that are worth a notification when a domain first reaches them. */ +const INTERESTING_ACTIONS: Action[] = ["would_purchase", "purchased", "purchase_failed", "error"]; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const isTrue = (value: string | undefined) => (value ?? "").trim().toLowerCase() === "true"; @@ -96,6 +131,96 @@ function toCsv(statuses: DomainStatus[]): string { 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)); + +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 = 600): 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 processDomain( client: InwxClient, domain: string, @@ -173,33 +298,34 @@ async function runCheck(env: Env): Promise { return statuses; } -function summarize(record: RunRecord): string { - if (record.error) return `INWX-Bot: Lauf fehlgeschlagen – ${record.error}`; - const counts: Record = {}; - for (const s of record.statuses) counts[s.action] = (counts[s.action] ?? 0) + 1; - const parts = Object.entries(counts).map(([k, v]) => `${k}: ${v}`); - const prefix = record.dryRun ? "INWX-Bot (Probelauf)" : "INWX-Bot"; - return `${prefix}: ${record.statuses.length} Domains geprüft – ${parts.join(", ") || "keine"}`; -} +const ACTION_LABELS_DE: Record = { + skipped: "übersprungen", + would_purchase: "verfügbar", + purchased: "gekauft", + purchase_failed: "Kauf fehlgeschlagen", + error: "Fehler", +}; -async function notify(env: Env, record: RunRecord): Promise { +async function notifyRun(env: Env, record: RunRecord, changes: DomainChange[]): Promise { if (!env.NOTIFY_WEBHOOK_URL) return; - // Only ping when something is worth knowing (available / bought / failed / errored). - const noteworthy = Boolean(record.error) || record.statuses.some((s) => s.action !== "skipped"); - if (!noteworthy) return; - const text = summarize(record); - const interesting = record.statuses.filter((s) => s.action !== "skipped"); - 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, statuses: interesting }), - }); - } catch { - // Notifications are best-effort. + // 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 sendWebhook(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 sendWebhook(env, `${prefix}: ${changes.length} Änderung(en) – ${detail}`, { changes }); } async function runAndStore(env: Env): Promise { @@ -211,9 +337,27 @@ async function runAndStore(env: Env): Promise { } 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)); - await notify(env, record); + + // 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; } @@ -294,9 +438,41 @@ async function refreshWhois(env: Env): Promise { const record: WhoisRecord = { timestamp, domains: results }; await env.INWX_BOT.put(WHOIS_KEY, JSON.stringify(record, null, 2)); + await processExpiryAlerts(env, results); return record; } +/** Notify once per crossed threshold as an owned/registered domain nears expiry. */ +async function processExpiryAlerts(env: Env, results: WhoisInfo[]): Promise { + const thresholds = parseThresholds(env.EXPIRY_ALERT_DAYS); + 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 sendWebhook(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, @@ -383,11 +559,12 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P // `?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(runAndStore(env).then(() => undefined)); + ctx.waitUntil(withLock(env, () => runAndStore(env)).then(() => undefined)); return json({ ok: true, started: true }, 202); } - const record = await runAndStore(env); - return json({ ok: !record.error, ...record }); + 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 === "GET" && path === "/api/whois") { @@ -397,11 +574,17 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P if (request.method === "POST" && path === "/api/whois/refresh") { if (url.searchParams.get("async") === "true") { - ctx.waitUntil(refreshWhois(env).then(() => undefined)); + ctx.waitUntil(withLock(env, () => refreshWhois(env)).then(() => undefined)); return json({ ok: true, started: true }, 202); } - const record = await refreshWhois(env); - return json({ ok: true, ...record }); + 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" } }); } return json({ error: "not found" }, 404); @@ -410,12 +593,13 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P export default { async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): Promise { // Run the availability check, then refresh WHOIS data (sequentially, so we - // never hold two INWX sessions at once). + // never hold two INWX sessions at once). A lock guards against overlap with + // a manually triggered run. ctx.waitUntil( - (async () => { + withLock(env, async () => { await runAndStore(env); await refreshWhois(env); - })(), + }).then(() => undefined), ); }, async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index a8f9200..a2524a7 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -22,5 +22,7 @@ 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" # Uncomment to test against the INWX OT&E sandbox instead of production: # INWX_API_URL = "https://api.ote.domrobot.com/jsonrpc/" From 31dad171dfc8b8d57cde82404b19233bb384008e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 13:34:55 +0000 Subject: [PATCH 08/15] Add per-domain modes, budget guard, and on-demand buy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain entries are now per-domain config objects ({domain, mode, maxPrice?, tags?, notes?}) instead of plain strings (plain strings / newline lists stay supported and default to mode "auto"): - mode "watch" only reports availability; "auto" registers when free unless DRY_RUN is on. - maxPrice skips an auto-purchase when the INWX price exceeds the budget; domain.check now also returns the price (best-effort). When a price cannot be determined and a budget is set, the purchase is skipped (safe default). - POST /api/buy registers one available domain on explicit request; it ignores DRY_RUN and the watch/auto mode (explicit user action) and is lock-guarded. Dashboard: the domain list is now an editable table (domain / mode / max price / tags), and the results table has a per-row "Kaufen" button (with confirmation) for available domains. Verified: tsc; dashboard client-script syntax/structure checks; unit tests of every processDomain decision branch (never buys on watch / dry-run / over-budget / unknown-price-with-budget; buys otherwise); and end-to-end against `wrangler dev` — domain-config round-trip incl. backward-compatible string/text input, and /api/buy validation (400 on empty, graceful error without creds). https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/README.md | 35 ++++-- cloudflare-worker/src/dashboard.ts | 105 ++++++++++++++-- cloudflare-worker/src/index.ts | 187 ++++++++++++++++++++++------- cloudflare-worker/src/inwx.ts | 18 ++- 4 files changed, 278 insertions(+), 67 deletions(-) diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index bb91107..f67d717 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -8,8 +8,11 @@ Workers), and stores the domain list and results in **Workers KV**. ## How it works - A **Cron Trigger** invokes the Worker on a schedule (default: daily 06:00 UTC). -- It logs in to INWX, checks each domain from the KV list with `domain.check`, - and — unless `DRY_RUN` is on — registers free ones with `domain.create`. +- It logs in to INWX and checks each domain from the KV list with `domain.check`. + 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 from the dashboard ("Kaufen"). - 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`). @@ -55,13 +58,21 @@ npx wrangler secret put INWX_NS2 # 3. Deploy. npx wrangler deploy -# 4. Seed the domain list — either in the dashboard at -# https://inwx-bot..workers.dev/ or via the API: +# 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 @@ -92,11 +103,12 @@ 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, -- edit and save the domain list, +- edit the domain list in a table (mode `auto`/`watch`, max price, tags), +- **buy an available domain on demand** ("Kaufen" button, with confirmation), - trigger a check immediately ("Jetzt prüfen"), - view the **WHOIS / domain-status table** (registered / expires / last changed / status per domain; expiry within 30 days is highlighted) and refresh it, -- download the results as CSV. +- review the run **history**, and download the results as CSV. A strict Content-Security-Policy (nonce-based, no external assets) is applied. @@ -121,18 +133,21 @@ The table refreshes on each cron run and via the "WHOIS aktualisieren" button. |---------------------------|----------------------------------------------------------| | `GET /` | Web dashboard (HTML). | | `GET /api/status` | Health/status: dry-run mode, last run time, last error. | -| `GET /api/domains` | Current domain list. | -| `PUT /api/domains` | Replace the list (newline-separated text or JSON array). | +| `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). | -`POST /api/run` and `POST /api/whois/refresh` return `409` if another run is -already in progress (best-effort KV lock). +`POST /api/run`, `POST /api/whois/refresh` and `POST /api/buy` 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. ## Local development diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts index 52cb1ee..15aa14d 100644 --- a/cloudflare-worker/src/dashboard.ts +++ b/cloudflare-worker/src/dashboard.ts @@ -40,7 +40,7 @@ main{max-width:980px;margin:0 auto;padding:20px;display:flex;flex-direction:colu .btn:disabled{opacity:.5;cursor:not-allowed} .btn-primary{background:var(--primary);color:var(--primary-fg);border-color:var(--primary)} .btn-ghost{background:transparent} -input,textarea{font:inherit;width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--fg)} +input,textarea,select{font:inherit;width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--fg)} textarea{resize:vertical} table{width:100%;border-collapse:collapse;font-size:14px} th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:top} @@ -101,8 +101,15 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo

Domain-Liste

- +

Modus auto registriert verfügbare Domains automatisch (sofern nicht im Probelauf); watch meldet nur. Max-Preis verhindert teure Auto-Käufe.

+
+ + + +
DomainModusMax-PreisTags
+
+
@@ -115,7 +122,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo
- +
DomainVerfügbarStatusDetailCode
DomainVerfügbarStatusDetailCode
@@ -229,10 +236,29 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo var c3 = document.createElement('td'); c3.appendChild(badge(s.action)); tr.appendChild(c3); var c4 = document.createElement('td'); c4.textContent = s.detail || ''; tr.appendChild(c4); var c5 = document.createElement('td'); c5.textContent = (s.api_code === null || s.api_code === undefined) ? '' : String(s.api_code); tr.appendChild(c5); + var c6 = document.createElement('td'); + if (s.action === 'would_purchase') { + var bb = document.createElement('button'); bb.className = 'btn'; bb.textContent = 'Kaufen'; + (function (dom, button) { button.addEventListener('click', function () { buyDomain(dom, button); }); })(s.domain, bb); + c6.appendChild(bb); + } + tr.appendChild(c6); tbody.appendChild(tr); } } + function buyDomain(domain, button) { + if (!window.confirm('Domain wirklich kostenpflichtig registrieren: ' + domain + ' ?')) { return; } + button.disabled = true; button.textContent = 'Kaufe…'; + api('/api/buy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ domain: domain }) }) + .then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); }) + .then(function (res) { + window.alert(res.ok ? ('Gekauft: ' + domain) : ('Nicht gekauft: ' + (res.detail || res.error || 'Fehler'))); + loadResults(); loadHistory(); + }) + .catch(function (e) { window.alert((e && e.auth) ? 'Nicht autorisiert.' : 'Fehler beim Kauf.'); button.disabled = false; button.textContent = 'Kaufen'; }); + } + function setMode(dryRun) { var m = el('mode'); if (dryRun) { m.textContent = 'Probelauf — kauft nicht'; m.className = 'badge badge-dry'; } @@ -264,15 +290,74 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo }); } + function makeInput(type, value, placeholder, cls) { + var inp = document.createElement('input'); + inp.type = type; + if (value !== undefined && value !== null) { inp.value = value; } + if (placeholder) { inp.placeholder = placeholder; } + if (cls) { inp.className = cls; } + return inp; + } + + function makeModeSelect(value) { + var sel = document.createElement('select'); sel.className = 'd-mode'; + var modes = [['auto', 'auto (kauft)'], ['watch', 'watch (beobachtet)']]; + var i; + for (i = 0; i < modes.length; i++) { + var o = document.createElement('option'); o.value = modes[i][0]; o.textContent = modes[i][1]; + if (modes[i][0] === value) { o.selected = true; } + sel.appendChild(o); + } + return sel; + } + + function addDomainRow(cfg) { + cfg = cfg || {}; + var tr = document.createElement('tr'); + var c1 = document.createElement('td'); c1.appendChild(makeInput('text', cfg.domain || '', 'example.de', 'd-domain')); tr.appendChild(c1); + var c2 = document.createElement('td'); c2.appendChild(makeModeSelect(cfg.mode === 'watch' ? 'watch' : 'auto')); tr.appendChild(c2); + var c3 = document.createElement('td'); + var price = makeInput('number', (cfg.maxPrice !== undefined && cfg.maxPrice !== null) ? cfg.maxPrice : '', '', 'd-price'); + price.min = '0'; price.step = '0.01'; c3.appendChild(price); tr.appendChild(c3); + var c4 = document.createElement('td'); c4.appendChild(makeInput('text', (cfg.tags || []).join(', '), 'tag1, tag2', 'd-tags')); tr.appendChild(c4); + var c5 = document.createElement('td'); + var rm = document.createElement('button'); rm.className = 'btn'; rm.textContent = '✕'; + rm.addEventListener('click', function () { tr.parentNode.removeChild(tr); }); + c5.appendChild(rm); tr.appendChild(c5); + el('domains-table').querySelector('tbody').appendChild(tr); + } + + function renderDomains(list) { + var tbody = el('domains-table').querySelector('tbody'); clearNode(tbody); + list = list || []; + var i; + for (i = 0; i < list.length; i++) { addDomainRow(list[i]); } + el('domain-count').textContent = list.length + ' Domains'; + } + + function collectDomains() { + var rows = el('domains-table').querySelectorAll('tbody tr'); + var out = []; + var i; + for (i = 0; i < rows.length; i++) { + var row = rows[i]; + var domain = row.querySelector('.d-domain').value.trim(); + if (!domain) { continue; } + var cfg = { domain: domain, mode: row.querySelector('.d-mode').value === 'watch' ? 'watch' : 'auto' }; + var priceVal = row.querySelector('.d-price').value.trim(); + if (priceVal !== '') { var p = Number(priceVal); if (!isNaN(p)) { cfg.maxPrice = p; } } + var tagsVal = row.querySelector('.d-tags').value.trim(); + if (tagsVal !== '') { cfg.tags = tagsVal.split(',').map(function (t) { return t.trim(); }).filter(Boolean); } + out.push(cfg); + } + return out; + } + function loadDomains() { return api('/api/domains').then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); - }).then(function (j) { - var list = (j && j.domains) || []; - el('domains').value = list.join(String.fromCharCode(10)); - el('domain-count').textContent = list.length + ' Domains'; - }); + }).then(function (j) { renderDomains((j && j.domains) || []); }); } function renderWhois(rec) { @@ -383,10 +468,12 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo el('logout').addEventListener('click', function () { setToken(''); showApp(false); }); el('refresh').addEventListener('click', function () { loadResults(); loadHistory(); }); + el('add-domain').addEventListener('click', function () { addDomainRow({ mode: 'auto' }); }); + el('save-domains').addEventListener('click', function () { var btn = this; btn.disabled = true; var msg = el('domains-msg'); show(msg, true); msg.textContent = 'Speichere…'; - api('/api/domains', { method: 'PUT', headers: { 'content-type': 'text/plain' }, body: el('domains').value }) + api('/api/domains', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(collectDomains()) }) .then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); }) .then(function (j) { msg.textContent = 'Gespeichert: ' + j.count + ' Domains'; el('domain-count').textContent = j.count + ' Domains'; }) .catch(function (e) { msg.textContent = (e && e.auth) ? 'Nicht autorisiert.' : 'Fehler beim Speichern.'; }) diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index beb7fa0..f2fd73b 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -77,6 +77,18 @@ interface DomainState { 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"; @@ -95,27 +107,55 @@ const INTERESTING_ACTIONS: Action[] = ["would_purchase", "purchased", "purchase_ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const isTrue = (value: string | undefined) => (value ?? "").trim().toLowerCase() === "true"; -function parseDomainList(raw: string): string[] { +/** 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; - if (Array.isArray(parsed)) { - return parsed.map((d) => String(d).trim()).filter(Boolean); - } + entries = Array.isArray(parsed) ? parsed : []; } catch { - // fall through to newline parsing + entries = trimmed.split(/\r?\n/); } + } else { + entries = trimmed.split(/\r?\n/); } - return trimmed - .split(/\r?\n/) - .map((d) => d.trim()) - .filter(Boolean); + return entries.map(normalizeConfig).filter((c): c is DomainConfig => c !== null); } -async function loadDomains(env: Env): Promise { +async function loadDomainConfigs(env: Env): Promise { const raw = await env.INWX_BOT.get(DOMAINS_KEY); - return raw ? parseDomainList(raw) : []; + return raw ? parseDomainConfigs(raw) : []; +} + +async function loadDomains(env: Env): Promise { + return (await loadDomainConfigs(env)).map((c) => c.domain); } function toCsv(statuses: DomainStatus[]): string { @@ -221,40 +261,51 @@ async function sendWebhook(env: Env, text: string, extra: Record { + const params: Record = { + domain, + registrant: accountInfo.defaultRegistrant, + admin: accountInfo.defaultAdmin, + tech: accountInfo.defaultTech, + billing: accountInfo.defaultBilling, + }; + if (ns.length > 0) params.ns = ns; + return params; +} + async function processDomain( client: InwxClient, - domain: string, + cfg: DomainConfig, accountInfo: AccountInfo, ns: string[], dryRun: boolean, ): Promise { - const available = await client.isDomainFree(domain); - if (!available) { - return { - domain, - available: false, - action: "skipped", - detail: "already registered", - api_code: 1000, - api_msg: "domain not available", - }; - } - if (dryRun) { - return { domain, available: true, action: "would_purchase", detail: "dry run – not purchased", api_code: null, api_msg: null }; - } - - const buyParams: Record = { + const domain = cfg.domain; + const { avail, price } = await client.checkDomain(domain); + const priceNote = price !== null ? ` (Preis ${price})` : ""; + const notBought = (detail: string): DomainStatus => ({ domain, - registrant: accountInfo.defaultRegistrant, - admin: accountInfo.defaultAdmin, - tech: accountInfo.defaultTech, - billing: accountInfo.defaultBilling, - }; - if (ns.length > 0) buyParams.ns = ns; + available: true, + action: "would_purchase", + detail, + api_code: null, + api_msg: null, + }); + + if (!avail) { + return { domain, available: false, action: "skipped", detail: "already registered", api_code: 1000, api_msg: "domain not available" }; + } + // Available — decide whether to actually register it. + if (cfg.mode === "watch") return notBought(`watch-only${priceNote}`); + if (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(buyParams); + const { success, code, msg } = await client.buyDomain(buyParamsFor(domain, accountInfo, ns)); if (success) { - return { domain, available: true, action: "purchased", detail: "success", api_code: code ?? null, api_msg: msg }; + return { domain, available: true, action: "purchased", detail: `success${priceNote}`, api_code: code ?? null, api_msg: msg }; } return { domain, available: true, action: "purchase_failed", detail: `Code ${code}: ${msg}`, api_code: code ?? null, api_msg: msg }; } @@ -275,19 +326,19 @@ async function runCheck(env: Env): Promise { const accountInfo = await client.getAccountInfo(); const ns = [env.INWX_NS1, env.INWX_NS2].filter((v): v is string => Boolean(v)); - const domains = await loadDomains(env); + const configs = await loadDomainConfigs(env); - for (let i = 0; i < domains.length; i++) { + for (let i = 0; i < configs.length; i++) { if (i > 0 && delayMs > 0) await sleep(delayMs); - const domain = domains[i]; + const cfg = configs[i]; try { - const status = await processDomain(client, domain, accountInfo, ns, dryRun); + const status = await processDomain(client, cfg, accountInfo, ns, dryRun); statuses.push(status); - console.log(`[${i + 1}/${domains.length}] ${domain} -> ${status.action}`); + console.log(`[${i + 1}/${configs.length}] ${cfg.domain} -> ${status.action}`); } catch (e) { const detail = e instanceof Error ? e.message : String(e); - statuses.push({ domain, available: null, action: "error", detail, api_code: null, api_msg: null }); - console.error(`[${i + 1}/${domains.length}] ${domain} -> error: ${detail}`); + 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 { @@ -298,6 +349,38 @@ async function runCheck(env: Env): Promise { 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 }> { + 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 } = await client.checkDomain(domain); + if (!avail) return { ok: false, action: "skipped", detail: "not available" }; + const accountInfo = await client.getAccountInfo(); + const ns = [env.INWX_NS1, env.INWX_NS2].filter((v): v is string => Boolean(v)); + const { success, code, msg } = await client.buyDomain(buyParamsFor(domain, accountInfo, ns)); + return success + ? { ok: true, action: "purchased", detail: "success", code } + : { ok: false, action: "purchase_failed", detail: `Code ${code}: ${msg}`, code }; + } catch (e) { + return { ok: false, action: "error", detail: e instanceof Error ? e.message : String(e) }; + } finally { + if (loggedIn) await client.logout().catch(() => {}); + } +} + const ACTION_LABELS_DE: Record = { skipped: "übersprungen", would_purchase: "verfügbar", @@ -546,10 +629,10 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P if (path === "/api/domains") { if (request.method === "GET") { - return json({ domains: await loadDomains(env) }); + return json({ domains: await loadDomainConfigs(env) }); } if (request.method === "PUT" || request.method === "POST") { - const domains = parseDomainList(await request.text()); + const domains = parseDomainConfigs(await request.text()); await env.INWX_BOT.put(DOMAINS_KEY, JSON.stringify(domains)); return json({ ok: true, count: domains.length, domains }); } @@ -567,6 +650,20 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P 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); + 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" } }); diff --git a/cloudflare-worker/src/inwx.ts b/cloudflare-worker/src/inwx.ts index b969202..6ec0319 100644 --- a/cloudflare-worker/src/inwx.ts +++ b/cloudflare-worker/src/inwx.ts @@ -112,10 +112,22 @@ export class InwxClient { } } - async isDomainFree(domain: string): Promise { + /** + * Check availability and (best-effort) price in a single `domain.check`. + * `price` is null when INWX does not return one for this domain. + */ + async checkDomain(domain: string): Promise<{ avail: boolean; price: number | null }> { const result = await this.call("domain.check", { domain }, "during domain check"); - const data = result.resData as { domain?: Array<{ avail?: number | boolean }> } | undefined; - return Boolean(data?.domain?.[0]?.avail); + const entry = (result.resData as { domain?: Array> } | undefined)?.domain?.[0] ?? {}; + const raw = entry.price ?? entry.checkPrice ?? null; + let price: number | null = null; + if (typeof raw === "number" && Number.isFinite(raw)) price = raw; + else if (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw))) price = Number(raw); + return { avail: Boolean(entry.avail), price }; + } + + async isDomainFree(domain: string): Promise { + return (await this.checkDomain(domain)).avail; } async getAccountInfo(): Promise { From 48817b66404646b42070388fe46b6b45428b9a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 13:54:08 +0000 Subject: [PATCH 09/15] Add unit tests + CI, Wrangler v4, Telegram, security headers, Dependabot Quality and operations hardening for the Worker: - Unit tests (vitest): 22 tests covering domain-config parsing, CSV escaping, run-change detection, expiry thresholds, RDAP parsing, and the RFC 6238 TOTP vectors. Pure helpers are re-exported from index.ts for testing. The CI "worker" job now runs `npm test` after the typecheck. - Wrangler bumped to v4 (removes the out-of-date warning; matches the Cloudflare Workers Build environment). Config is unchanged and verified via dev + deploy dry-run. - Telegram notifications: set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID to receive alerts in Telegram in addition to (or instead of) the webhook. Notifications now fan out to every configured channel. - Security headers: dashboard CSP gains `frame-ancestors 'none'`, and responses send `X-Content-Type-Options: nosniff` (+ `Referrer-Policy: no-referrer` on the dashboard). - Dependabot config for npm (cloudflare-worker), pip, and github-actions. Verified: `npm ci` + typecheck + 22 tests green; wrangler v4 dev serves the dashboard with the new headers; notification fan-out confirmed against a local webhook receiver (with correct de-duplication). https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- .github/dependabot.yml | 18 + .github/workflows/ci.yml | 1 + cloudflare-worker/.dev.vars.sample | 4 + cloudflare-worker/README.md | 13 +- cloudflare-worker/package-lock.json | 2644 +++++++++++++++++++------- cloudflare-worker/package.json | 6 +- cloudflare-worker/src/index.ts | 48 +- cloudflare-worker/src/whois.ts | 4 +- cloudflare-worker/test/core.test.ts | 124 ++ cloudflare-worker/test/totp.test.ts | 14 + cloudflare-worker/test/whois.test.ts | 48 + cloudflare-worker/vitest.config.ts | 8 + 12 files changed, 2282 insertions(+), 650 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 cloudflare-worker/test/core.test.ts create mode 100644 cloudflare-worker/test/totp.test.ts create mode 100644 cloudflare-worker/test/whois.test.ts create mode 100644 cloudflare-worker/vitest.config.ts 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 index ccc5a34..2dc86cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,4 @@ jobs: cache-dependency-path: cloudflare-worker/package-lock.json - run: npm ci - run: npm run typecheck + - run: npm test diff --git a/cloudflare-worker/.dev.vars.sample b/cloudflare-worker/.dev.vars.sample index 057390d..ec5bcd1 100644 --- a/cloudflare-worker/.dev.vars.sample +++ b/cloudflare-worker/.dev.vars.sample @@ -15,6 +15,10 @@ ADMIN_TOKEN = "choose-a-long-random-token" # 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 default nameservers used on purchase. # INWX_NS1 = "ns.inwx.de" # INWX_NS2 = "ns2.inwx.de" diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index f67d717..9eaad7c 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -20,10 +20,10 @@ Workers), and stores the domain list and results in **Workers KV**. 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 — 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). +- Optional notifications via a Slack/Discord-compatible webhook **and/or + Telegram** — 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. > **Safety first:** `DRY_RUN` defaults to `"true"`, so out of the box the bot @@ -92,8 +92,12 @@ Non-secret settings live in `wrangler.toml` under `[vars]`: 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), `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`. + The schedule is controlled by the `crons` array in `wrangler.toml`. ## Dashboard @@ -155,6 +159,7 @@ attempts the real (paid) registration. 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` diff --git a/cloudflare-worker/package-lock.json b/cloudflare-worker/package-lock.json index 1b6b51e..218f272 100644 --- a/cloudflare-worker/package-lock.json +++ b/cloudflare-worker/package-lock.json @@ -10,31 +10,29 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20241011.0", "typescript": "^5.6.0", - "wrangler": "^3.80.0" + "vitest": "^2.1.9", + "wrangler": "^4.0.0" } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", - "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "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", - "dependencies": { - "mime": "^3.0.0" - }, "engines": { - "node": ">=16.13" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", - "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "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.14", - "workerd": "^1.20250124.0" + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -43,9 +41,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", - "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "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" ], @@ -60,9 +58,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", - "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "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" ], @@ -77,9 +75,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", - "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "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" ], @@ -94,9 +92,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", - "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "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" ], @@ -111,9 +109,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", - "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "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" ], @@ -158,34 +156,27 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild-plugins/node-globals-polyfill": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", - "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "esbuild": "*" - } - }, - "node_modules/@esbuild-plugins/node-modules-polyfill": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", - "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "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": "ISC", - "dependencies": { - "escape-string-regexp": "^4.0.0", - "rollup-plugin-node-polyfills": "^0.2.1" - }, - "peerDependencies": { - "esbuild": "*" + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" } }, "node_modules/@esbuild/android-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", - "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "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" ], @@ -200,9 +191,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", - "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "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" ], @@ -217,9 +208,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", - "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -234,9 +225,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", - "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "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" ], @@ -251,9 +242,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", - "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "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" ], @@ -268,9 +259,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", - "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "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" ], @@ -285,9 +276,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", - "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "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" ], @@ -302,9 +293,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", - "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -319,9 +310,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", - "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "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" ], @@ -336,9 +327,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", - "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -353,9 +344,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", - "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "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" ], @@ -370,9 +361,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", - "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -387,9 +378,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", - "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "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" ], @@ -404,9 +395,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", - "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "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" ], @@ -421,9 +412,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", - "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -438,9 +429,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", - "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "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" ], @@ -454,10 +445,27 @@ "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.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", - "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -471,10 +479,27 @@ "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.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", - "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -488,10 +513,27 @@ "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.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", - "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "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" ], @@ -506,9 +548,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", - "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -523,9 +565,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", - "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "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" ], @@ -540,9 +582,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", - "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "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" ], @@ -556,20 +598,20 @@ "node": ">=12" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "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": ">=14" + "node": ">=18" } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "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" ], @@ -586,13 +628,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "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" ], @@ -609,13 +651,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "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" ], @@ -630,9 +672,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "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" ], @@ -647,9 +689,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "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" ], @@ -664,9 +706,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "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" ], @@ -680,10 +722,44 @@ "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.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "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" ], @@ -698,9 +774,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "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" ], @@ -715,9 +791,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "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" ], @@ -732,9 +808,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "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" ], @@ -749,9 +825,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "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" ], @@ -768,13 +844,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "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" ], @@ -791,13 +867,59 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@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.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "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" ], @@ -814,13 +936,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "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" ], @@ -837,13 +959,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "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" ], @@ -860,13 +982,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "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" ], @@ -883,13 +1005,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], @@ -897,8 +1019,28 @@ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@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" }, @@ -907,9 +1049,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "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" ], @@ -927,9 +1069,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "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" ], @@ -974,594 +1116,1823 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "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", - "engines": { - "node": ">=0.4.0" + "dependencies": { + "kleur": "^4.1.5" } }, - "node_modules/as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "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": { - "printable-characters": "^1.0.42" + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" } }, - "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==", + "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/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "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, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } + "os": [ + "android" + ] }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "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, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } + "os": [ + "android" + ] }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "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 + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "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, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } + "os": [ + "darwin" + ] }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "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", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "dev": true, - "license": "MIT" + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "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" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "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==", + "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": "Apache-2.0", + "license": "MIT", "optional": true, - "engines": { - "node": ">=8" - } + "os": [ + "linux" + ] }, - "node_modules/esbuild": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", - "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "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, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.17.19", - "@esbuild/android-arm64": "0.17.19", - "@esbuild/android-x64": "0.17.19", - "@esbuild/darwin-arm64": "0.17.19", - "@esbuild/darwin-x64": "0.17.19", - "@esbuild/freebsd-arm64": "0.17.19", - "@esbuild/freebsd-x64": "0.17.19", - "@esbuild/linux-arm": "0.17.19", - "@esbuild/linux-arm64": "0.17.19", - "@esbuild/linux-ia32": "0.17.19", - "@esbuild/linux-loong64": "0.17.19", - "@esbuild/linux-mips64el": "0.17.19", - "@esbuild/linux-ppc64": "0.17.19", - "@esbuild/linux-riscv64": "0.17.19", - "@esbuild/linux-s390x": "0.17.19", - "@esbuild/linux-x64": "0.17.19", - "@esbuild/netbsd-x64": "0.17.19", - "@esbuild/openbsd-x64": "0.17.19", - "@esbuild/sunos-x64": "0.17.19", - "@esbuild/win32-arm64": "0.17.19", - "@esbuild/win32-ia32": "0.17.19", - "@esbuild/win32-x64": "0.17.19" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "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", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/estree-walker": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "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" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/exit-hook": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", - "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "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", - "engines": { - "node": ">=6" + "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://github.com/sponsors/sindresorhus" + "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/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "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/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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": [ - "darwin" + "aix" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=18" } }, - "node_modules/get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "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": "Unlicense", - "dependencies": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" + "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/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "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": "BSD-2-Clause" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "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 + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "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", - "dependencies": { - "sourcemap-codec": "^1.4.8" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "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", - "bin": { - "mime": "cli.js" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=10.0.0" + "node": ">=18" } }, - "node_modules/miniflare": { - "version": "3.20250718.3", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", - "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "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", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "acorn": "8.14.0", - "acorn-walk": "8.3.2", - "exit-hook": "2.2.1", - "glob-to-regexp": "0.4.1", - "stoppable": "1.1.0", - "undici": "^5.28.5", - "workerd": "1.20250718.0", - "ws": "8.18.0", - "youch": "3.3.4", - "zod": "3.22.3" - }, - "bin": { - "miniflare": "bootstrap.js" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=16.13" + "node": ">=18" } }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "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", - "bin": { - "mustache": "bin/mustache" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "dev": true, - "license": "MIT" - }, - "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": "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/printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", - "dev": true, - "license": "Unlicense" - }, - "node_modules/rollup-plugin-inject": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", - "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "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", - "dependencies": { - "estree-walker": "^0.6.1", - "magic-string": "^0.25.3", - "rollup-pluginutils": "^2.8.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/rollup-plugin-node-polyfills": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", - "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "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", - "dependencies": { - "rollup-plugin-inject": "^3.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/rollup-pluginutils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", - "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "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", - "dependencies": { - "estree-walker": "^0.6.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "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": "ISC", + "license": "MIT", "optional": true, - "bin": { - "semver": "bin/semver.js" - }, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "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, - "hasInstallScript": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, + "os": [ + "linux" + ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "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, - "dependencies": { - "is-arrayish": "^0.3.1" + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "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": "BSD-3-Clause", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true, - "license": "MIT" - }, - "node_modules/stacktracey": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", - "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "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": "Unlicense", - "dependencies": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "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": ">=4", - "npm": ">=6" + "node": ">=18" } }, - "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==", + "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": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "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" + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "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", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=14.0" + "node": ">=18" } }, - "node_modules/unenv": { - "version": "2.0.0-rc.14", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", - "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "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", - "dependencies": { - "defu": "^6.1.4", - "exsolve": "^1.0.1", - "ohash": "^2.0.10", - "pathe": "^2.0.3", - "ufo": "^1.5.4" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/workerd": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", - "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "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, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20250718.0", - "@cloudflare/workerd-darwin-arm64": "1.20250718.0", - "@cloudflare/workerd-linux-64": "1.20250718.0", - "@cloudflare/workerd-linux-arm64": "1.20250718.0", - "@cloudflare/workerd-windows-64": "1.20250718.0" + "node": ">=18" } }, - "node_modules/wrangler": { - "version": "3.114.17", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", - "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "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, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.3.4", - "@cloudflare/unenv-preset": "2.0.2", - "@esbuild-plugins/node-globals-polyfill": "0.2.3", - "@esbuild-plugins/node-modules-polyfill": "0.2.2", - "blake3-wasm": "2.1.5", - "esbuild": "0.17.19", - "miniflare": "3.20250718.3", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.14", - "workerd": "1.20250718.0" - }, + "hasInstallScript": true, + "license": "MIT", "bin": { - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=16.17.0" + "node": ">=18" }, "optionalDependencies": { - "fsevents": "~2.3.2", - "sharp": "^0.33.5" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20250408.0" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } + "@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.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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": { @@ -1581,25 +2952,28 @@ } }, "node_modules/youch": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", - "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "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": { - "cookie": "^0.7.1", - "mustache": "^4.2.0", - "stacktracey": "^2.1.8" + "@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/zod": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", - "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "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", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "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 index 0d8d5c0..f9ae21c 100644 --- a/cloudflare-worker/package.json +++ b/cloudflare-worker/package.json @@ -8,11 +8,13 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "tail": "wrangler tail", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241011.0", "typescript": "^5.6.0", - "wrangler": "^3.80.0" + "vitest": "^2.1.9", + "wrangler": "^4.0.0" } } diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index f2fd73b..460d4e6 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -24,6 +24,8 @@ export interface Env { 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; @@ -261,6 +263,24 @@ async function sendWebhook(env: Env, text: string, extra: Record { + 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. + } +} + +/** Fan out a notification to all configured channels (webhook + Telegram). */ +async function notify(env: Env, text: string, extra: Record = {}): Promise { + await Promise.all([sendWebhook(env, text, extra), sendTelegram(env, text)]); +} + function buyParamsFor(domain: string, accountInfo: AccountInfo, ns: string[]): Record { const params: Record = { domain, @@ -390,13 +410,13 @@ const ACTION_LABELS_DE: Record = { }; async function notifyRun(env: Env, record: RunRecord, changes: DomainChange[]): Promise { - if (!env.NOTIFY_WEBHOOK_URL) return; + if (!env.NOTIFY_WEBHOOK_URL && !env.TELEGRAM_BOT_TOKEN) 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 sendWebhook(env, `INWX-Bot: Lauf fehlgeschlagen – ${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; @@ -408,7 +428,7 @@ async function notifyRun(env: Env, record: RunRecord, changes: DomainChange[]): 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 sendWebhook(env, `${prefix}: ${changes.length} Änderung(en) – ${detail}`, { changes }); + await notify(env, `${prefix}: ${changes.length} Änderung(en) – ${detail}`, { changes }); } async function runAndStore(env: Env): Promise { @@ -552,14 +572,14 @@ async function processExpiryAlerts(env: Env, results: WhoisInfo[]): Promise 0) { - await sendWebhook(env, `INWX-Bot: Ablauf-Warnung – ${alerts.join("; ")}`, { expiring: alerts }); + 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" }, + headers: { "content-type": "application/json; charset=utf-8", "x-content-type-options": "nosniff" }, }); } @@ -587,9 +607,11 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": - `default-src 'none'; base-uri 'none'; form-action 'self'; connect-src 'self'; ` + - `img-src data:; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'`, + `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", }, }); } @@ -703,3 +725,15 @@ export default { return handleFetch(request, env, ctx); }, } satisfies ExportedHandler; + +// Re-exported for unit tests (see test/). +export { + normalizeConfig, + parseDomainConfigs, + toCsv, + countsOf, + detectRunChanges, + parseThresholds, + daysUntil, +}; +export type { DomainConfig, DomainStatus, Action, StateMap }; diff --git a/cloudflare-worker/src/whois.ts b/cloudflare-worker/src/whois.ts index 18cb2fc..83398fc 100644 --- a/cloudflare-worker/src/whois.ts +++ b/cloudflare-worker/src/whois.ts @@ -30,7 +30,7 @@ interface RdapEntity { vcardArray?: unknown; } -interface RdapResponse { +export interface RdapResponse { events?: RdapEvent[]; status?: string[]; entities?: RdapEntity[]; @@ -79,7 +79,7 @@ function registrarName(entities: RdapEntity[] | undefined): string | null { return null; } -function parseRdap(domain: string, data: RdapResponse): WhoisInfo { +export function parseRdap(domain: string, data: RdapResponse): WhoisInfo { return { domain, source: "rdap", 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/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..a17e449 --- /dev/null +++ b/cloudflare-worker/test/whois.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { parseRdap, lookupRdap } 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"); + }); +}); 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", + }, +}); From affe6dbd464b5085b6021f9acd20b996b5022974 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 14:08:16 +0000 Subject: [PATCH 10/15] Add settings UI, ad-hoc check, audit log, login throttling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final batch of the dashboard/ops UX: - Runtime settings: dryRun / apiDelayMs / expiryAlertDays can be overridden via KV (settings:config) and edited in a dashboard panel — no redeploy needed. wrangler.toml [vars] remain the safe defaults; turning dry-run off prompts for confirmation. New GET/PUT /api/settings; the run, whois and status paths now read the effective settings. - Ad-hoc quick check: POST /api/check ({domain} or {keyword,tlds[]}) and a dashboard form report availability + price without touching the watch list. - Audit log: token-authenticated mutating actions (run, buy, domains.save, whois.refresh, settings.save, check) are recorded to a capped KV list (audit:log), exposed via GET /api/audit and shown in the dashboard. - Login throttling: protected endpoints count failed token attempts per client IP and return 429 after a threshold (5-minute window); success clears the counter. Fail-open on KV errors. Verified: tsc; 29 unit tests (incl. new mergeSettings / expandCheckTargets); dashboard syntax/structure checks; and end-to-end against `wrangler dev` — settings override flips DRY_RUN at runtime (reflected in /api/status), audit log records actions with IP, /api/check validates input + degrades without creds, and throttling returns 429 after 10 bad attempts (other IPs unaffected). https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/README.md | 29 ++- cloudflare-worker/src/dashboard.ts | 127 ++++++++++++- cloudflare-worker/src/index.ts | 232 ++++++++++++++++++++++-- cloudflare-worker/test/settings.test.ts | 35 ++++ 4 files changed, 401 insertions(+), 22 deletions(-) create mode 100644 cloudflare-worker/test/settings.test.ts diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 9eaad7c..3e27ebf 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -110,11 +110,21 @@ enter your `ADMIN_TOKEN` once (kept in the browser's `sessionStorage`) to - edit the domain list in a table (mode `auto`/`watch`, max price, tags), - **buy an available domain on demand** ("Kaufen" button, with confirmation), - 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, -- review the run **history**, and download the results as CSV. +- review the run **history** and the **audit log**, and download the CSV, +- change **settings** (dry-run, API delay, expiry thresholds) without redeploying. -A strict Content-Security-Policy (nonce-based, no external assets) is applied. +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. ### WHOIS / domain status @@ -147,11 +157,16 @@ The table refreshes on each cron run and via the "WHOIS aktualisieren" button. | `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). | - -`POST /api/run`, `POST /api/whois/refresh` and `POST /api/buy` 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. +| `GET /api/settings` | Effective settings + the stored KV override. | +| `PUT /api/settings` | Override `dryRun` / `apiDelayMs` / `expiryAlertDays`. | +| `POST /api/check` | Ad-hoc check: `{"domain"}` or `{"keyword","tlds":[]}`. | +| `GET /api/audit` | Recent admin actions (capped, newest first). | + +`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 diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts index 15aa14d..0d6b969 100644 --- a/cloudflare-worker/src/dashboard.ts +++ b/cloudflare-worker/src/dashboard.ts @@ -60,6 +60,9 @@ th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;le .badge-live{background:#dcfce7;color:#166534} footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);font-size:12px} .mt{margin-top:10px} +.srow{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--border)} +.srow input[type=checkbox]{width:auto} +.srow input[type=number],.srow input[type=text]{max-width:220px} [hidden]{display:none !important} @@ -159,6 +162,46 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo + +
+
+

Schnell-Check

+ prüft ohne zur Liste hinzuzufügen +
+
+ + + +
+
+ + + +
DomainVerfügbarPreis / Info
+
+ +
+ +
+

Einstellungen

+
+ + + +
+
+
+ +
+

Audit-Log

letzte Aktionen
+
+ + + +
ZeitpunktAktionDetailIP
+
+ +
INWX Bot · Cron-gesteuerter Domain-Check auf Cloudflare Workers
@@ -432,8 +475,58 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo }).then(function (list) { renderHistory(list); }); } + function renderCheckResults(list) { + var tbody = el('check-results').querySelector('tbody'); clearNode(tbody); + list = list || []; + show(el('check-empty'), list.length === 0); + var i; + for (i = 0; i < list.length; i++) { + var r = list[i]; + var tr = document.createElement('tr'); + addCell(tr, r.domain); + addCell(tr, r.error ? '–' : (r.available ? 'ja' : 'nein'), r.error ? 'muted' : ''); + addCell(tr, (r.price !== undefined && r.price !== null) ? String(r.price) : (r.error || '–'), r.error ? 'muted' : ''); + tbody.appendChild(tr); + } + } + + function loadSettings() { + return api('/api/settings').then(function (r) { + if (r.status === 401) { throw { auth: true }; } + return r.json(); + }).then(function (j) { + var s = (j && j.effective) || {}; + el('set-dryrun').checked = !!s.dryRun; + el('set-delay').value = (s.apiDelayMs !== undefined && s.apiDelayMs !== null) ? s.apiDelayMs : ''; + el('set-thresholds').value = (s.expiryAlertDays || []).join(','); + }); + } + + function renderAudit(list) { + var tbody = el('audit').querySelector('tbody'); clearNode(tbody); + list = list || []; + show(el('audit-empty'), list.length === 0); + var i; + for (i = 0; i < list.length; i++) { + var a = list[i]; + var tr = document.createElement('tr'); + addCell(tr, fmtTime(a.timestamp)); + addCell(tr, a.action || ''); + addCell(tr, a.detail || ''); + addCell(tr, a.ip || '–'); + tbody.appendChild(tr); + } + } + + function loadAudit() { + return api('/api/audit').then(function (r) { + if (r.status === 401) { throw { auth: true }; } + return r.json(); + }).then(function (list) { renderAudit(list); }); + } + function loadAuthed() { - return Promise.all([loadDomains(), loadResults(), loadWhois(), loadHistory()]).then(function () { + return Promise.all([loadDomains(), loadResults(), loadWhois(), loadHistory(), loadSettings(), loadAudit()]).then(function () { showApp(true); }).catch(function (e) { if (e && e.auth) { @@ -525,6 +618,38 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo .catch(function (e) { el('whois-time').textContent = (e && e.auth) ? 'Nicht autorisiert.' : 'Fehler beim Start.'; btn.disabled = false; }); }); + el('check-btn').addEventListener('click', function () { + var input = el('check-input').value.trim(); + var tldsRaw = el('check-tlds').value.trim(); + if (!input) { return; } + var body = tldsRaw + ? { keyword: input, tlds: tldsRaw.split(',').map(function (t) { return t.trim(); }).filter(Boolean) } + : { domain: input }; + var btn = this; btn.disabled = true; btn.textContent = 'Prüfe…'; + api('/api/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + .then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); }) + .then(function (j) { renderCheckResults((j && j.results) || []); if (j && j.ok === false && j.error) { window.alert(j.error); } }) + .catch(function (e) { window.alert((e && e.auth) ? 'Nicht autorisiert.' : 'Fehler beim Check.'); }) + .then(function () { btn.disabled = false; btn.textContent = 'Prüfen'; }); + }); + + el('save-settings').addEventListener('click', function () { + var dryRun = el('set-dryrun').checked; + if (!dryRun && !window.confirm('DRY_RUN ausschalten? Der Bot registriert dann verfügbare auto-Domains WIRKLICH (kostenpflichtig).')) { return; } + var override = { dryRun: dryRun }; + var delay = el('set-delay').value.trim(); + if (delay !== '') { var d = Number(delay); if (!isNaN(d)) { override.apiDelayMs = d; } } + var th = el('set-thresholds').value.trim(); + if (th !== '') { override.expiryAlertDays = th.split(',').map(function (x) { return Number(x.trim()); }).filter(function (n) { return !isNaN(n); }); } + var btn = this; btn.disabled = true; + var msg = el('settings-msg'); show(msg, true); msg.textContent = 'Speichere…'; + api('/api/settings', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(override) }) + .then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); }) + .then(function () { msg.textContent = 'Gespeichert.'; loadPublicStatus(); loadAudit(); }) + .catch(function (e) { msg.textContent = (e && e.auth) ? 'Nicht autorisiert.' : 'Fehler.'; }) + .then(function () { btn.disabled = false; }); + }); + loadPublicStatus(); if (getToken()) { loadAuthed(); } else { showApp(false); } })(); diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 460d4e6..16176ce 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -102,10 +102,37 @@ 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; /** 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[]; +} + +/** Partial overrides stored in KV via the dashboard settings panel. */ +interface SettingsOverride { + dryRun?: boolean; + apiDelayMs?: number; + expiryAlertDays?: number[]; +} + +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"; @@ -192,6 +219,73 @@ async function readJson(env: Env, key: string, fallback: T): Promise { 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), + }; +} + +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, + }; +} + +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}`); +} + async function appendHistory(env: Env, summary: RunSummary): Promise { const history = await readJson(env, HISTORY_KEY, []); history.unshift(summary); @@ -330,12 +424,12 @@ async function processDomain( return { domain, available: true, action: "purchase_failed", detail: `Code ${code}: ${msg}`, api_code: code ?? null, api_msg: msg }; } -async function runCheck(env: Env): Promise { +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 dryRun = isTrue(env.DRY_RUN); - const delayMs = env.API_DELAY_MS ? Number(env.API_DELAY_MS) : 1000; + const dryRun = settings.dryRun; + const delayMs = settings.apiDelayMs; const client = new InwxClient(env.INWX_API_URL || LIVE_API_URL); const statuses: DomainStatus[] = []; @@ -401,6 +495,40 @@ async function buyDomainNow( } } +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); + const out: CheckResult[] = []; + let loggedIn = false; + try { + await client.login(env.INWX_USERNAME, env.INWX_PASSWORD, env.INWX_SHARED_SECRET); + loggedIn = true; + for (let i = 0; i < targets.length; i++) { + if (i > 0 && settings.apiDelayMs > 0) await sleep(settings.apiDelayMs); + try { + const { avail, price } = await client.checkDomain(targets[i]); + out.push({ domain: targets[i], available: avail, price }); + } catch (e) { + out.push({ domain: targets[i], error: e instanceof Error ? e.message : String(e) }); + } + } + } finally { + if (loggedIn) await client.logout().catch(() => {}); + } + return out; +} + const ACTION_LABELS_DE: Record = { skipped: "übersprungen", would_purchase: "verfügbar", @@ -433,10 +561,11 @@ async function notifyRun(env: Env, record: RunRecord, changes: DomainChange[]): async function runAndStore(env: Env): Promise { const timestamp = new Date().toISOString(); - const dryRun = isTrue(env.DRY_RUN); + const settings = await loadSettings(env); + const dryRun = settings.dryRun; let record: RunRecord; try { - record = { timestamp, dryRun, statuses: await runCheck(env) }; + record = { timestamp, dryRun, statuses: await runCheck(env, settings) }; } catch (e) { record = { timestamp, dryRun, statuses: [], error: e instanceof Error ? e.message : String(e) }; } @@ -498,7 +627,8 @@ async function enrichDomain(client: InwxClient | null, domain: string): Promise< async function refreshWhois(env: Env): Promise { const timestamp = new Date().toISOString(); - const delayMs = env.API_DELAY_MS ? Number(env.API_DELAY_MS) : 1000; + const settings = await loadSettings(env); + const delayMs = settings.apiDelayMs; const domains = await loadDomains(env); // Logging in is optional: without INWX credentials we still serve RDAP data. @@ -541,13 +671,12 @@ async function refreshWhois(env: Env): Promise { const record: WhoisRecord = { timestamp, domains: results }; await env.INWX_BOT.put(WHOIS_KEY, JSON.stringify(record, null, 2)); - await processExpiryAlerts(env, results); + 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[]): Promise { - const thresholds = parseThresholds(env.EXPIRY_ALERT_DAYS); +async function processExpiryAlerts(env: Env, results: WhoisInfo[], thresholds: number[]): Promise { const state = await loadState(env); const alerts: string[] = []; @@ -618,16 +747,17 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P // Public, low-detail status endpoint (no domain details leaked). if (request.method === "GET" && path === "/api/status") { - const [raw, whoisRaw] = await Promise.all([ + 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: isTrue(env.DRY_RUN), + dryRun: settings.dryRun, authConfigured: Boolean(env.ADMIN_TOKEN), lastRun: last?.timestamp ?? null, lastRunDomains: last?.statuses.length ?? 0, @@ -636,8 +766,17 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P }); } - // Everything below requires the admin bearer token. - if (!isAuthorized(request, env)) return unauthorized(); + // 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)) ?? ""; @@ -656,11 +795,13 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P 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") { @@ -681,6 +822,7 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P } 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 }); @@ -692,6 +834,7 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P } 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); @@ -706,6 +849,65 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P 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; + } + 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 === "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" }, + }); + } + return json({ error: "not found" }, 404); } @@ -735,5 +937,7 @@ export { detectRunChanges, parseThresholds, daysUntil, + mergeSettings, + expandCheckTargets, }; -export type { DomainConfig, DomainStatus, Action, StateMap }; +export type { DomainConfig, DomainStatus, Action, StateMap, Settings, SettingsOverride }; diff --git a/cloudflare-worker/test/settings.test.ts b/cloudflare-worker/test/settings.test.ts new file mode 100644 index 0000000..4a1757b --- /dev/null +++ b/cloudflare-worker/test/settings.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { mergeSettings, expandCheckTargets, type Settings } from "../src/index"; + +const base: Settings = { dryRun: true, apiDelayMs: 1000, expiryAlertDays: [30, 14, 7, 1] }; + +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]); + }); +}); + +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([]); + }); +}); From 6370601031cb96a5119a5684985975c0db2d8255 Mon Sep 17 00:00:00 2001 From: Kolja <37582741+koljasagorski@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:55:07 +0200 Subject: [PATCH 11/15] chore: sync local working tree to GitHub Auto-sync per always-keep-GitHub-in-sync policy. Co-Authored-By: Claude Opus 4.8 (1M context) --- .wrangler/cache/wrangler-account.json | 6 ++++++ CLAUDE.md | 8 ++++++++ cloudflare-worker/wrangler.toml | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .wrangler/cache/wrangler-account.json create mode 100644 CLAUDE.md diff --git a/.wrangler/cache/wrangler-account.json b/.wrangler/cache/wrangler-account.json new file mode 100644 index 0000000..f866dcc --- /dev/null +++ b/.wrangler/cache/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "9a562df9759b0d525872d399986f10ef", + "name": "Cloudflare@koljasagorski.de's Account" + } +} \ No newline at end of file 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/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index a2524a7..52b1984 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -3,7 +3,7 @@ main = "src/index.ts" compatibility_date = "2024-09-23" # Runs the domain check on a schedule. Default: every day at 06:00 UTC. -# Adjust or add entries — https://developers.cloudflare.com/workers/configuration/cron-triggers/ +# Adjust or add entries ??? https://developers.cloudflare.com/workers/configuration/cron-triggers/ [triggers] crons = ["0 6 * * *"] @@ -15,7 +15,7 @@ binding = "INWX_BOT" id = "4a136c11e00c4e02b19206117ff820f8" # Non-secret configuration. Secrets (credentials, tokens) are set with -# `wrangler secret put` — see README.md. +# `wrangler secret put` ??? see README.md. [vars] # Delay between INWX API calls in milliseconds (rate limiting). API_DELAY_MS = "1000" From 25da2c41c50402f4ea568602002358b5cf0dd448 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:42:40 +0000 Subject: [PATCH 12/15] Buy UX (price + notify + refresh), notes, settings reset; cache cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish and correctness fixes: - Manual buy now: confirmation dialog shows the price, the result row flips to "purchased" immediately, a notification is sent, and the stored results/state are updated so a reload stays consistent (previously a manual buy was silent and the table kept showing "available"). - domain.check's price is surfaced on every DomainStatus and shown in a new "Preis" column. - The per-domain "notes" field is now editable in the dashboard table (it existed in the model but was silently dropped by the UI). - Settings can be reset to the wrangler.toml defaults (DELETE /api/settings + dashboard button), and the panel warns that overrides persist across deploys. - Lock TTL raised 600s -> 1800s so a long run does not lose the lock mid-flight. - Stop tracking the accidentally committed `.wrangler/` cache (account info / build cache) and gitignore `.wrangler/` + `node_modules/` at the repo root. Verified: tsc; 29 unit tests; dashboard syntax/structure checks; and end-to-end against `wrangler dev` — notes round-trip, settings override + reset (status reflects the change and falls back to the env default), and audit logging. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- .gitignore | 4 ++ .wrangler/cache/wrangler-account.json | 6 --- cloudflare-worker/README.md | 15 +++++-- cloudflare-worker/src/dashboard.ts | 56 ++++++++++++++++++++------- cloudflare-worker/src/index.ts | 50 +++++++++++++++++++----- 5 files changed, 98 insertions(+), 33 deletions(-) delete mode 100644 .wrangler/cache/wrangler-account.json diff --git a/.gitignore b/.gitignore index b71e2b0..4afceb7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ __pycache__/ *.pyc .pytest_cache/ .ruff_cache/ + +# Wrangler local cache / state (should never be committed) +.wrangler/ +node_modules/ diff --git a/.wrangler/cache/wrangler-account.json b/.wrangler/cache/wrangler-account.json deleted file mode 100644 index f866dcc..0000000 --- a/.wrangler/cache/wrangler-account.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "account": { - "id": "9a562df9759b0d525872d399986f10ef", - "name": "Cloudflare@koljasagorski.de's Account" - } -} \ No newline at end of file diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 3e27ebf..5d00081 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -106,15 +106,17 @@ 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, -- edit the domain list in a table (mode `auto`/`watch`, max price, tags), -- **buy an available domain on demand** ("Kaufen" button, with confirmation), +- 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, - review the run **history** and the **audit log**, and download the CSV, -- change **settings** (dry-run, API delay, expiry thresholds) without redeploying. +- change **settings** (dry-run, API delay, expiry thresholds) without redeploying, + or **reset** them back 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. @@ -126,6 +128,10 @@ and the admin token is protected by per-IP brute-force throttling. 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: @@ -159,6 +165,7 @@ The table refreshes on each cron run and via the "WHOIS aktualisieren" button. | `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). | diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts index 0d6b969..ca6955b 100644 --- a/cloudflare-worker/src/dashboard.ts +++ b/cloudflare-worker/src/dashboard.ts @@ -107,7 +107,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo

Modus auto registriert verfügbare Domains automatisch (sofern nicht im Probelauf); watch meldet nur. Max-Preis verhindert teure Auto-Käufe.

- +
DomainModusMax-PreisTags
DomainModusMax-PreisTagsNotiz
@@ -125,7 +125,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo
- +
DomainVerfügbarStatusDetailCode
DomainVerfügbarStatusDetailCodePreis
@@ -189,7 +189,11 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo -
+
+ + +
+

Hinweis: Gespeicherte Werte überschreiben wrangler.toml dauerhaft – auch über Deploys hinweg – bis du auf Standard zurücksetzt.

@@ -279,25 +283,37 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo var c3 = document.createElement('td'); c3.appendChild(badge(s.action)); tr.appendChild(c3); var c4 = document.createElement('td'); c4.textContent = s.detail || ''; tr.appendChild(c4); var c5 = document.createElement('td'); c5.textContent = (s.api_code === null || s.api_code === undefined) ? '' : String(s.api_code); tr.appendChild(c5); - var c6 = document.createElement('td'); + var c6 = document.createElement('td'); c6.textContent = (s.price === null || s.price === undefined) ? '' : String(s.price); tr.appendChild(c6); + var c7 = document.createElement('td'); if (s.action === 'would_purchase') { var bb = document.createElement('button'); bb.className = 'btn'; bb.textContent = 'Kaufen'; - (function (dom, button) { button.addEventListener('click', function () { buyDomain(dom, button); }); })(s.domain, bb); - c6.appendChild(bb); + (function (dom, price, button, row) { + button.addEventListener('click', function () { buyDomain(dom, price, button, row); }); + })(s.domain, s.price, bb, tr); + c7.appendChild(bb); } - tr.appendChild(c6); + tr.appendChild(c7); tbody.appendChild(tr); } } - function buyDomain(domain, button) { - if (!window.confirm('Domain wirklich kostenpflichtig registrieren: ' + domain + ' ?')) { return; } + function buyDomain(domain, price, button, row) { + var priceText = (price === null || price === undefined) ? '' : ' für ' + price; + if (!window.confirm('Domain kostenpflichtig registrieren: ' + domain + priceText + ' ?')) { return; } button.disabled = true; button.textContent = 'Kaufe…'; api('/api/buy', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ domain: domain }) }) .then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); }) .then(function (res) { - window.alert(res.ok ? ('Gekauft: ' + domain) : ('Nicht gekauft: ' + (res.detail || res.error || 'Fehler'))); - loadResults(); loadHistory(); + if (res.ok) { + // Reflect the purchase immediately, then refresh the live data. + var statusCell = row.children[2]; clearNode(statusCell); statusCell.appendChild(badge('purchased')); + button.parentNode.removeChild(button); + window.alert('Registriert: ' + domain); + } else { + window.alert('Nicht gekauft: ' + (res.detail || res.error || 'Fehler')); + button.disabled = false; button.textContent = 'Kaufen'; + } + loadResults(); loadHistory(); loadAudit(); }) .catch(function (e) { window.alert((e && e.auth) ? 'Nicht autorisiert.' : 'Fehler beim Kauf.'); button.disabled = false; button.textContent = 'Kaufen'; }); } @@ -363,10 +379,11 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo var price = makeInput('number', (cfg.maxPrice !== undefined && cfg.maxPrice !== null) ? cfg.maxPrice : '', '', 'd-price'); price.min = '0'; price.step = '0.01'; c3.appendChild(price); tr.appendChild(c3); var c4 = document.createElement('td'); c4.appendChild(makeInput('text', (cfg.tags || []).join(', '), 'tag1, tag2', 'd-tags')); tr.appendChild(c4); - var c5 = document.createElement('td'); + var c5 = document.createElement('td'); c5.appendChild(makeInput('text', cfg.notes || '', 'Notiz', 'd-notes')); tr.appendChild(c5); + var c6 = document.createElement('td'); var rm = document.createElement('button'); rm.className = 'btn'; rm.textContent = '✕'; rm.addEventListener('click', function () { tr.parentNode.removeChild(tr); }); - c5.appendChild(rm); tr.appendChild(c5); + c6.appendChild(rm); tr.appendChild(c6); el('domains-table').querySelector('tbody').appendChild(tr); } @@ -391,6 +408,8 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo if (priceVal !== '') { var p = Number(priceVal); if (!isNaN(p)) { cfg.maxPrice = p; } } var tagsVal = row.querySelector('.d-tags').value.trim(); if (tagsVal !== '') { cfg.tags = tagsVal.split(',').map(function (t) { return t.trim(); }).filter(Boolean); } + var notesVal = row.querySelector('.d-notes').value.trim(); + if (notesVal !== '') { cfg.notes = notesVal; } out.push(cfg); } return out; @@ -650,6 +669,17 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo .then(function () { btn.disabled = false; }); }); + el('reset-settings').addEventListener('click', function () { + if (!window.confirm('Alle Overrides löschen und auf die wrangler.toml-Standardwerte zurücksetzen?')) { return; } + var btn = this; btn.disabled = true; + var msg = el('settings-msg'); show(msg, true); msg.textContent = 'Setze zurück…'; + api('/api/settings', { method: 'DELETE' }) + .then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.json(); }) + .then(function () { msg.textContent = 'Zurückgesetzt.'; loadSettings(); loadPublicStatus(); loadAudit(); }) + .catch(function (e) { msg.textContent = (e && e.auth) ? 'Nicht autorisiert.' : 'Fehler.'; }) + .then(function () { btn.disabled = false; }); + }); + loadPublicStatus(); if (getToken()) { loadAuthed(); } else { showApp(false); } })(); diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 16176ce..21005d5 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -40,6 +40,7 @@ interface DomainStatus { detail: string; api_code: number | null; api_msg: string | null; + price?: number | null; } interface RunRecord { @@ -295,7 +296,7 @@ async function appendHistory(env: Env, summary: RunSummary): Promise { // 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 = 600): Promise { +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; @@ -404,10 +405,11 @@ async function processDomain( 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" }; + 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}`); @@ -419,9 +421,9 @@ async function processDomain( const { success, code, msg } = await client.buyDomain(buyParamsFor(domain, accountInfo, ns)); if (success) { - return { domain, available: true, action: "purchased", detail: `success${priceNote}`, api_code: code ?? null, api_msg: msg }; + 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 }; + 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 { @@ -471,7 +473,7 @@ async function runCheck(env: Env, settings: Settings): Promise { async function buyDomainNow( env: Env, domain: string, -): Promise<{ ok: boolean; action: Action; detail: string; code?: number }> { +): 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" }; } @@ -480,14 +482,17 @@ async function buyDomainNow( try { await client.login(env.INWX_USERNAME, env.INWX_PASSWORD, env.INWX_SHARED_SECRET); loggedIn = true; - const { avail } = await client.checkDomain(domain); - if (!avail) return { ok: false, action: "skipped", detail: "not available" }; + 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 { success, code, msg } = await client.buyDomain(buyParamsFor(domain, accountInfo, ns)); - return success - ? { ok: true, action: "purchased", detail: "success", code } - : { ok: false, action: "purchase_failed", detail: `Code ${code}: ${msg}`, code }; + 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 { @@ -495,6 +500,25 @@ async function buyDomainNow( } } +/** 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; @@ -877,6 +901,12 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P 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") { From d9c7bdc012390c570f213bd53f97435eb3bb0e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:57:32 +0000 Subject: [PATCH 13/15] Scale runs: batch domain.check, split cron, registration options, heartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the subrequest-limit ceiling and registration/liveness gaps: - Availability is now checked in batches via a single domain.check call per ~30 domains (InwxClient.checkDomains) instead of one call per domain, so the run path stays well under Cloudflare's per-invocation subrequest limit. The ad-hoc /api/check uses the same batched path. - The cron is split into two triggers — one for the availability run (06:00), one for the WHOIS refresh (06:30) — so each invocation gets its own subrequest budget. The scheduled handler routes by event.cron (unknown cron values run both as a fallback). wrangler.toml ships both crons. - Registration options renewalMode / period / transferLock are applied to domain.create (defaults: AUTORENEW + transfer-locked), configurable via env vars, the dashboard settings panel, or PUT /api/settings. - Optional HEARTBEAT_URL is pinged after every cron invocation as a dead-man's-switch so a stalled cron is detected externally. - Lock TTL already raised in the previous PR; this keeps long batched runs safe. Verified: tsc; 32 unit tests (added chunk + registration-merge coverage); dashboard syntax/structure checks; and end-to-end against `wrangler dev --test-scheduled` — the run cron updates lastRun, the WHOIS cron updates whoisLastRun, each invocation pings the heartbeat, and the registration options round-trip through /api/settings. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/.dev.vars.sample | 5 + cloudflare-worker/README.md | 28 +++-- cloudflare-worker/src/dashboard.ts | 15 +++ cloudflare-worker/src/index.ts | 138 +++++++++++++++++++----- cloudflare-worker/src/inwx.ts | 32 +++++- cloudflare-worker/test/settings.test.ts | 27 ++++- cloudflare-worker/wrangler.toml | 13 ++- 7 files changed, 213 insertions(+), 45 deletions(-) diff --git a/cloudflare-worker/.dev.vars.sample b/cloudflare-worker/.dev.vars.sample index ec5bcd1..990e9a2 100644 --- a/cloudflare-worker/.dev.vars.sample +++ b/cloudflare-worker/.dev.vars.sample @@ -19,6 +19,11 @@ ADMIN_TOKEN = "choose-a-long-random-token" # 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 default nameservers used on purchase. # INWX_NS1 = "ns.inwx.de" # INWX_NS2 = "ns2.inwx.de" diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 5d00081..443249c 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -7,12 +7,16 @@ Workers), and stores the domain list and results in **Workers KV**. ## How it works -- A **Cron Trigger** invokes the Worker on a schedule (default: daily 06:00 UTC). -- It logs in to INWX and checks each domain from the KV list with `domain.check`. - 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 from the dashboard ("Kaufen"). +- 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`). @@ -25,6 +29,8 @@ Workers), and stores the domain list and results in **Workers KV**. 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"` @@ -89,16 +95,22 @@ Non-secret settings live in `wrangler.toml` under `[vars]`: | `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. | 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), -`INWX_NS1`, `INWX_NS2`. +`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 controlled by the `crons` array in `wrangler.toml`. +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 diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts index ca6955b..07935db 100644 --- a/cloudflare-worker/src/dashboard.ts +++ b/cloudflare-worker/src/dashboard.ts @@ -188,6 +188,15 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo + + +
@@ -518,6 +527,9 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo el('set-dryrun').checked = !!s.dryRun; el('set-delay').value = (s.apiDelayMs !== undefined && s.apiDelayMs !== null) ? s.apiDelayMs : ''; el('set-thresholds').value = (s.expiryAlertDays || []).join(','); + el('set-renewal').value = s.renewalMode || 'AUTORENEW'; + el('set-period').value = s.period || ''; + el('set-lock').checked = s.transferLock !== false; }); } @@ -660,6 +672,9 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo if (delay !== '') { var d = Number(delay); if (!isNaN(d)) { override.apiDelayMs = d; } } var th = el('set-thresholds').value.trim(); if (th !== '') { override.expiryAlertDays = th.split(',').map(function (x) { return Number(x.trim()); }).filter(function (n) { return !isNaN(n); }); } + override.renewalMode = el('set-renewal').value; + override.period = el('set-period').value.trim(); + override.transferLock = el('set-lock').checked; var btn = this; btn.disabled = true; var msg = el('settings-msg'); show(msg, true); msg.textContent = 'Speichere…'; api('/api/settings', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(override) }) diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 21005d5..df2e0e7 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -29,6 +29,10 @@ export interface Env { API_DELAY_MS?: string; DRY_RUN?: string; EXPIRY_ALERT_DAYS?: string; + RENEWAL_MODE?: string; + PERIOD?: string; + TRANSFER_LOCK?: string; + HEARTBEAT_URL?: string; } type Action = "skipped" | "purchased" | "purchase_failed" | "would_purchase" | "error"; @@ -109,6 +113,13 @@ 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"]; @@ -118,6 +129,10 @@ 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. */ @@ -125,6 +140,9 @@ interface SettingsOverride { dryRun?: boolean; apiDelayMs?: number; expiryAlertDays?: number[]; + renewalMode?: string; + period?: string; + transferLock?: boolean; } interface AuditEntry { @@ -226,6 +244,11 @@ function envSettings(env: Env): Settings { 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, }; } @@ -240,6 +263,9 @@ function mergeSettings(base: Settings, ov: SettingsOverride): Settings { 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, }; } @@ -287,6 +313,12 @@ function expandCheckTargets(input: { domain?: string; keyword?: string; tlds?: s 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); @@ -376,7 +408,12 @@ async function notify(env: Env, text: string, extra: Record = { await Promise.all([sendWebhook(env, text, extra), sendTelegram(env, text)]); } -function buyParamsFor(domain: string, accountInfo: AccountInfo, ns: string[]): Record { +function buyParamsFor( + domain: string, + accountInfo: AccountInfo, + ns: string[], + reg: Pick, +): Record { const params: Record = { domain, registrant: accountInfo.defaultRegistrant, @@ -385,18 +422,22 @@ function buyParamsFor(domain: string, accountInfo: AccountInfo, ns: string[]): R 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[], - dryRun: boolean, + settings: Settings, ): Promise { const domain = cfg.domain; - const { avail, price } = await client.checkDomain(domain); + const { avail, price } = check; const priceNote = price !== null ? ` (Preis ${price})` : ""; const notBought = (detail: string): DomainStatus => ({ domain, @@ -413,13 +454,13 @@ async function processDomain( } // Available — decide whether to actually register it. if (cfg.mode === "watch") return notBought(`watch-only${priceNote}`); - if (dryRun) return notBought(`dry run – not purchased${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)); + 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 }; } @@ -430,7 +471,6 @@ 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 dryRun = settings.dryRun; const delayMs = settings.apiDelayMs; const client = new InwxClient(env.INWX_API_URL || LIVE_API_URL); const statuses: DomainStatus[] = []; @@ -444,11 +484,29 @@ async function runCheck(env: Env, settings: Settings): Promise { 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++) { - if (i > 0 && delayMs > 0) await sleep(delayMs); const cfg = configs[i]; + const check = checks.get(cfg.domain.toLowerCase()) ?? { avail: false, price: null }; try { - const status = await processDomain(client, cfg, accountInfo, ns, dryRun); + // 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) { @@ -486,7 +544,8 @@ async function buyDomainNow( 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 { success, code, msg } = await client.buyDomain(buyParamsFor(domain, accountInfo, ns)); + 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})` : ""}`); @@ -533,24 +592,24 @@ async function runAdhocCheck(env: Env, targets: string[]): Promise 0 && settings.apiDelayMs > 0) await sleep(settings.apiDelayMs); - try { - const { avail, price } = await client.checkDomain(targets[i]); - out.push({ domain: targets[i], available: avail, price }); - } catch (e) { - out.push({ domain: targets[i], error: e instanceof Error ? e.message : String(e) }); - } + 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(() => {}); } - return out; } const ACTION_LABELS_DE: Record = { @@ -897,6 +956,9 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P 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) }); @@ -941,17 +1003,38 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P 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 { - // Run the availability check, then refresh WHOIS data (sequentially, so we - // never hold two INWX sessions at once). A lock guards against overlap with - // a manually triggered run. - ctx.waitUntil( - withLock(env, async () => { + 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); - }).then(() => undefined), - ); + }; + } 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); @@ -969,5 +1052,6 @@ export { 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 index 6ec0319..25d4d0b 100644 --- a/cloudflare-worker/src/inwx.ts +++ b/cloudflare-worker/src/inwx.ts @@ -116,14 +116,36 @@ export class InwxClient { * 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] ?? {}; - const raw = entry.price ?? entry.checkPrice ?? null; - let price: number | null = null; - if (typeof raw === "number" && Number.isFinite(raw)) price = raw; - else if (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw))) price = Number(raw); - return { avail: Boolean(entry.avail), price }; + 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 { diff --git a/cloudflare-worker/test/settings.test.ts b/cloudflare-worker/test/settings.test.ts index 4a1757b..e6d7a0c 100644 --- a/cloudflare-worker/test/settings.test.ts +++ b/cloudflare-worker/test/settings.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect } from "vitest"; -import { mergeSettings, expandCheckTargets, type Settings } from "../src/index"; +import { mergeSettings, expandCheckTargets, chunk, type Settings } from "../src/index"; -const base: Settings = { dryRun: true, apiDelayMs: 1000, expiryAlertDays: [30, 14, 7, 1] }; +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", () => { @@ -16,6 +23,12 @@ describe("mergeSettings", () => { 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", () => { @@ -33,3 +46,13 @@ describe("expandCheckTargets", () => { 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/wrangler.toml b/cloudflare-worker/wrangler.toml index 52b1984..deb1316 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -2,10 +2,12 @@ name = "inwx-bot" main = "src/index.ts" compatibility_date = "2024-09-23" -# Runs the domain check on a schedule. Default: every day at 06:00 UTC. -# Adjust or add entries ??? https://developers.cloudflare.com/workers/configuration/cron-triggers/ +# 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 * * *"] +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 @@ -24,5 +26,10 @@ API_DELAY_MS = "1000" 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 # Uncomment to test against the INWX OT&E sandbox instead of production: # INWX_API_URL = "https://api.ote.domrobot.com/jsonrpc/" From 8c8a7be84b7ed78af4df8865f60015d9fafb0e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 09:06:53 +0000 Subject: [PATCH 14/15] Add email notifications, backup export/import, table filter + sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channels and comfort improvements: - Email notifications via Resend: set RESEND_API_KEY + EMAIL_TO + EMAIL_FROM to also receive alerts by email. Notifications now fan out to every configured channel (webhook + Telegram + email), gated by a single hasNotifyChannel check. - Backup: GET /api/export downloads a JSON bundle of the domain list + settings override (with a Content-Disposition filename); POST /api/import restores it. A "Backup herunterladen" button is added to the dashboard; imports are audited. - Dashboard comfort: the results and WHOIS tables get a live filter input and click-to-sort column headers (numeric-aware), re-applied after each refresh. Verified: tsc; 32 unit tests; dashboard syntax/structure checks; and end-to-end against `wrangler dev` — export returns the bundle (+ download header), import restores domains and settings (confirmed via the API), and the action is audited. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/.dev.vars.sample | 5 +++ cloudflare-worker/README.md | 20 ++++++---- cloudflare-worker/src/dashboard.ts | 60 ++++++++++++++++++++++++++++- cloudflare-worker/src/index.ts | 62 ++++++++++++++++++++++++++++-- 4 files changed, 136 insertions(+), 11 deletions(-) diff --git a/cloudflare-worker/.dev.vars.sample b/cloudflare-worker/.dev.vars.sample index 990e9a2..91faada 100644 --- a/cloudflare-worker/.dev.vars.sample +++ b/cloudflare-worker/.dev.vars.sample @@ -24,6 +24,11 @@ ADMIN_TOKEN = "choose-a-long-random-token" # 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/README.md b/cloudflare-worker/README.md index 443249c..e6b76cf 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -24,10 +24,10 @@ Workers), and stores the domain list and results in **Workers KV**. 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 **and/or - Telegram** — 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). +- 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. @@ -102,6 +102,7 @@ Non-secret settings live in `wrangler.toml` under `[vars]`: 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 @@ -126,9 +127,12 @@ enter your `ADMIN_TOKEN` once (kept in the browser's `sessionStorage`) to - 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, -- review the run **history** and the **audit log**, and download the CSV, -- change **settings** (dry-run, API delay, expiry thresholds) without redeploying, - or **reset** them back to the `wrangler.toml` defaults. +- **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. @@ -180,6 +184,8 @@ The table refreshes on each cron run and via the "WHOIS aktualisieren" button. | `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). diff --git a/cloudflare-worker/src/dashboard.ts b/cloudflare-worker/src/dashboard.ts index 07935db..d06c5d4 100644 --- a/cloudflare-worker/src/dashboard.ts +++ b/cloudflare-worker/src/dashboard.ts @@ -45,6 +45,8 @@ textarea{resize:vertical} table{width:100%;border-collapse:collapse;font-size:14px} th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:top} th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.03em} +thead th{cursor:pointer;user-select:none} +.filter-input{max-width:260px} .table-wrap{overflow-x:auto} .muted{color:var(--muted);font-size:13px} .error{color:var(--err);font-size:13px} @@ -121,7 +123,10 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo

Ergebnisse

- +
+ + +
@@ -136,6 +141,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo

WHOIS / Domain-Status

+
@@ -201,6 +207,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo
+

Hinweis: Gespeicherte Werte überschreiben wrangler.toml dauerhaft – auch über Deploys hinweg – bis du auf Standard zurücksetzt.

@@ -304,6 +311,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo tr.appendChild(c7); tbody.appendChild(tr); } + applyFilter(el('results-filter'), el('results')); } function buyDomain(domain, price, button, row) { @@ -461,6 +469,7 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo var cSrc = document.createElement('td'); cSrc.textContent = w.source || '–'; cSrc.className = 'muted'; tr.appendChild(cSrc); tbody.appendChild(tr); } + applyFilter(el('whois-filter'), el('whois')); } function loadWhois() { @@ -695,6 +704,55 @@ footer{max-width:980px;margin:0 auto;padding:8px 20px 28px;color:var(--muted);fo .then(function () { btn.disabled = false; }); }); + el('export-btn').addEventListener('click', function () { + api('/api/export').then(function (r) { if (r.status === 401) { throw { auth: true }; } return r.text(); }).then(function (text) { + var blob = new Blob([text], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); a.href = url; a.download = 'inwx-bot-backup.json'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + URL.revokeObjectURL(url); + }).catch(function (e) { window.alert((e && e.auth) ? 'Nicht autorisiert.' : 'Fehler beim Export.'); }); + }); + + // Client-side comfort: live row filtering + click-to-sort on read-only tables. + function applyFilter(input, table) { + if (!input || !table) { return; } + var q = (input.value || '').trim().toLowerCase(); + var rows = table.querySelectorAll('tbody tr'); + for (var i = 0; i < rows.length; i++) { + rows[i].hidden = q !== '' && rows[i].textContent.toLowerCase().indexOf(q) === -1; + } + } + function attachFilter(input, table) { + if (!input || !table) { return; } + input.addEventListener('input', function () { applyFilter(input, table); }); + } + function makeSortable(table) { + if (!table) { return; } + var ths = table.querySelectorAll('thead th'); + for (var i = 0; i < ths.length; i++) { + (function (idx, th) { + th.addEventListener('click', function () { + var tbody = table.querySelector('tbody'); + var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr')); + var asc = th.getAttribute('data-asc') !== 'true'; + rows.sort(function (a, b) { + var x = a.children[idx] ? a.children[idx].textContent : ''; + var y = b.children[idx] ? b.children[idx].textContent : ''; + var nx = parseFloat(x), ny = parseFloat(y); + if (!isNaN(nx) && !isNaN(ny)) { return asc ? nx - ny : ny - nx; } + return asc ? x.localeCompare(y) : y.localeCompare(x); + }); + for (var r = 0; r < rows.length; r++) { tbody.appendChild(rows[r]); } + th.setAttribute('data-asc', asc ? 'true' : 'false'); + }); + })(i, ths[i]); + } + } + makeSortable(el('results')); makeSortable(el('whois')); makeSortable(el('history')); + attachFilter(el('results-filter'), el('results')); + attachFilter(el('whois-filter'), el('whois')); + loadPublicStatus(); if (getToken()) { loadAuthed(); } else { showApp(false); } })(); diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index df2e0e7..0d8278a 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -33,6 +33,9 @@ export interface Env { PERIOD?: string; TRANSFER_LOCK?: string; HEARTBEAT_URL?: string; + RESEND_API_KEY?: string; + EMAIL_TO?: string; + EMAIL_FROM?: string; } type Action = "skipped" | "purchased" | "purchase_failed" | "would_purchase" | "error"; @@ -403,9 +406,27 @@ async function sendTelegram(env: Env, text: string): Promise { } } -/** Fan out a notification to all configured channels (webhook + Telegram). */ +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)]); + await Promise.all([sendWebhook(env, text, extra), sendTelegram(env, text), sendEmail(env, text)]); } function buyParamsFor( @@ -621,7 +642,7 @@ const ACTION_LABELS_DE: Record = { }; async function notifyRun(env: Env, record: RunRecord, changes: DomainChange[]): Promise { - if (!env.NOTIFY_WEBHOOK_URL && !env.TELEGRAM_BOT_TOKEN) return; + if (!hasNotifyChannel(env)) return; // De-duplicate fatal run errors so a persistent failure does not spam. if (record.error) { @@ -1000,6 +1021,41 @@ async function handleFetch(request: Request, env: Env, ctx: ExecutionContext): P }); } + 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); } From 48a39b62818c63e97de7cd83e50332a08b6f6aab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 09:25:24 +0000 Subject: [PATCH 15/15] Add opt-in port-43 WHOIS fallback; fix .de false-"available" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For ccTLDs that rdap.org doesn't cover (e.g. .de), RDAP returns 404 — which the WHOIS table previously misread as "available" for registered domains. Now: - For such ccTLDs the table reports availability as *unknown* instead of falsely "available" (RDAP stays authoritative for gTLDs). - Optional port-43 WHOIS fallback (WHOIS_PORT43="true", off by default) queries the registry over TCP (cloudflare:sockets) for the few mapped ccTLDs, parses registered/expires/updated/status, and detects "free" responses. It is time-boxed (5 s, with background socket close) so a blocked/non-responsive server can never stall the refresh — a hang found and fixed during testing. DENIC still never publishes registration/expiry; this mainly fills status + last-change, and many registries block datacenter IPs (then it yields nothing). Verified: tsc; 35 unit tests (added parseWhoisText for gTLD + DENIC formats and whoisServerFor); and end-to-end against `wrangler dev` — default keeps .de fast and "unknown" (not "available"), and with the flag on the refresh returns in ~5s (bounded by the timeout) instead of hanging. https://claude.ai/code/session_01KH9YazP7LmiLgvbA3NhW6Y --- cloudflare-worker/README.md | 12 ++- cloudflare-worker/src/index.ts | 12 ++- cloudflare-worker/src/whois.ts | 139 ++++++++++++++++++++++++++- cloudflare-worker/test/whois.test.ts | 36 ++++++- cloudflare-worker/wrangler.toml | 3 + 5 files changed, 192 insertions(+), 10 deletions(-) diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index e6b76cf..7dfb616 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -98,6 +98,7 @@ Non-secret settings live in `wrangler.toml` under `[vars]`: | `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`, @@ -154,9 +155,14 @@ 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. Note that some - ccTLDs — notably **.de** (DENIC) — do not publish registration/expiry over - RDAP, so those columns may stay empty for domains you don't own. +- **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. diff --git a/cloudflare-worker/src/index.ts b/cloudflare-worker/src/index.ts index 0d8278a..74b613d 100644 --- a/cloudflare-worker/src/index.ts +++ b/cloudflare-worker/src/index.ts @@ -12,7 +12,7 @@ */ import { dashboardPage } from "./dashboard"; import { InwxClient, type AccountInfo } from "./inwx"; -import { lookupRdap, type WhoisInfo } from "./whois"; +import { lookupWhois, type WhoisInfo } from "./whois"; export interface Env { INWX_BOT: KVNamespace; @@ -36,6 +36,7 @@ export interface Env { RESEND_API_KEY?: string; EMAIL_TO?: string; EMAIL_FROM?: string; + WHOIS_PORT43?: string; } type Action = "skipped" | "purchased" | "purchase_failed" | "would_purchase" | "error"; @@ -716,8 +717,8 @@ function inwxToWhois(domain: string, info: Record): WhoisInfo { }; } -/** Prefer authoritative INWX data for owned domains; fall back to RDAP. */ -async function enrichDomain(client: InwxClient | null, domain: string): Promise { +/** 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); @@ -726,13 +727,14 @@ async function enrichDomain(client: InwxClient | null, domain: string): Promise< // not owned / API hiccup -> fall back to RDAP } } - return lookupRdap(domain); + 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. @@ -754,7 +756,7 @@ async function refreshWhois(env: Env): Promise { for (let i = 0; i < domains.length; i++) { if (i > 0 && delayMs > 0) await sleep(delayMs); try { - results.push(await enrichDomain(client, domains[i])); + results.push(await enrichDomain(client, domains[i], port43)); } catch (e) { results.push({ domain: domains[i], diff --git a/cloudflare-worker/src/whois.ts b/cloudflare-worker/src/whois.ts index 83398fc..7061888 100644 --- a/cloudflare-worker/src/whois.ts +++ b/cloudflare-worker/src/whois.ts @@ -10,7 +10,7 @@ export interface WhoisInfo { domain: string; - source: "inwx" | "rdap" | "none"; + source: "inwx" | "rdap" | "whois" | "none"; available: boolean | null; status: string[]; registered: string | null; @@ -115,3 +115,140 @@ export async function lookupRdap(domain: string): Promise { 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/whois.test.ts b/cloudflare-worker/test/whois.test.ts index a17e449..8aaba3c 100644 --- a/cloudflare-worker/test/whois.test.ts +++ b/cloudflare-worker/test/whois.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { parseRdap, lookupRdap } from "../src/whois"; +import { parseRdap, lookupRdap, parseWhoisText, whoisServerFor } from "../src/whois"; describe("parseRdap", () => { it("extracts events, status and registrar", () => { @@ -46,3 +46,37 @@ describe("lookupRdap", () => { 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/wrangler.toml b/cloudflare-worker/wrangler.toml index deb1316..347050c 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -31,5 +31,8 @@ EXPIRY_ALERT_DAYS = "30,14,7,1" 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/"