From e316748d05d9d382a73cfb902ad0dd20dcf4838b Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Wed, 1 Apr 2026 02:48:28 -0400 Subject: [PATCH] feat: enhance DDNS functionality with environment-specific types and validation - Introduced DdnsEnv interface to define environment bindings for Cloudflare DDNS. - Added validation utilities for IP addresses and environment variable parsing. - Created a mock DNS API for testing, allowing for in-memory record management. - Implemented integration tests for DDNS update logic, including authentication and hostname validation. - Updated TypeScript configuration to include new types and ensure compatibility. - Refactored wrangler configuration for improved clarity and added cron triggers for scheduled tasks. Signed-off-by: Okiki Ojo --- README.md | 198 ++- migrations/0001_add_tasks_table.sql | 9 - migrations/0001_ddns_logs.sql | 17 + package.json | 41 +- pnpm-lock.yaml | 1648 ++++++++++------------- pnpm-workspace.yaml | 4 + schema.json | 199 +++ src/cloudflare-api.ts | 129 ++ src/ddns.ts | 163 +++ src/endpoints/dummyEndpoint.ts | 47 - src/endpoints/health.ts | 19 + src/endpoints/synology.ts | 113 ++ src/endpoints/tasks/base.ts | 23 - src/endpoints/tasks/router.ts | 15 - src/endpoints/tasks/taskCreate.ts | 17 - src/endpoints/tasks/taskDelete.ts | 9 - src/endpoints/tasks/taskList.ts | 12 - src/endpoints/tasks/taskRead.ts | 9 - src/endpoints/tasks/taskUpdate.ts | 16 - src/endpoints/update.ts | 176 +++ src/index.ts | 77 +- src/logging.ts | 65 + src/types.ts | 81 +- src/validation.ts | 119 ++ tests/bindings.d.ts | 4 +- tests/helpers.ts | 141 ++ tests/integration/dummyEndpoint.test.ts | 27 - tests/integration/health.test.ts | 10 + tests/integration/synology.test.ts | 186 +++ tests/integration/tasks.test.ts | 268 ---- tests/integration/update.test.ts | 251 ++++ tests/tsconfig.json | 12 +- tests/vitest.config.mts | 42 +- wrangler.jsonc | 19 +- 34 files changed, 2646 insertions(+), 1520 deletions(-) delete mode 100644 migrations/0001_add_tasks_table.sql create mode 100644 migrations/0001_ddns_logs.sql create mode 100644 pnpm-workspace.yaml create mode 100644 schema.json create mode 100644 src/cloudflare-api.ts create mode 100644 src/ddns.ts delete mode 100644 src/endpoints/dummyEndpoint.ts create mode 100644 src/endpoints/health.ts create mode 100644 src/endpoints/synology.ts delete mode 100644 src/endpoints/tasks/base.ts delete mode 100644 src/endpoints/tasks/router.ts delete mode 100644 src/endpoints/tasks/taskCreate.ts delete mode 100644 src/endpoints/tasks/taskDelete.ts delete mode 100644 src/endpoints/tasks/taskList.ts delete mode 100644 src/endpoints/tasks/taskRead.ts delete mode 100644 src/endpoints/tasks/taskUpdate.ts create mode 100644 src/endpoints/update.ts create mode 100644 src/logging.ts create mode 100644 src/validation.ts create mode 100644 tests/helpers.ts delete mode 100644 tests/integration/dummyEndpoint.test.ts create mode 100644 tests/integration/health.test.ts create mode 100644 tests/integration/synology.test.ts delete mode 100644 tests/integration/tasks.test.ts create mode 100644 tests/integration/update.test.ts diff --git a/README.md b/README.md index e74ca7a..8d44c25 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,164 @@ -# OpenAPI Template +# Cloudflare DDNS -[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/templates/tree/main/chanfana-openapi-template) +A Cloudflare Worker that keeps DNS records in sync with your changing public IP address. Designed for Synology NAS devices but works with any HTTP client. -![OpenAPI Template Preview](https://imagedelivery.net/wSMYJvS3Xw-n339CbDyDIA/91076b39-1f5b-46f6-7f14-536a6f183000/public) +When your NAS or a cron script calls this worker, it reads the caller's IP from the request, compares it to the existing Cloudflare DNS record, and creates or patches the record if needed. Responses follow the DynDNS2 protocol so Synology DSM recognizes them natively. - +## Features -This is a Cloudflare Worker with OpenAPI 3.1 Auto Generation and Validation using [chanfana](https://github.com/cloudflare/chanfana) and [Hono](https://github.com/honojs/hono). +- Synology DSM custom DDNS provider compatibility (`GET /nic/update`) +- JSON API for scripts and automation (`POST /update` with OpenAPI docs) +- IPv4 (A) and IPv6 (AAAA) support +- D1-backed audit log with automatic cleanup +- Hostname allowlist to limit what records can be changed +- Configurable proxied/DNS-only mode and TTL -This is an example project made to be used as a quick start into building OpenAPI compliant Workers that generates the -`openapi.json` schema automatically from code and validates the incoming request to the defined parameters or request body. +## Prerequisites -This template includes various endpoints, a D1 database, and integration tests using [Vitest](https://vitest.dev/) as examples. In endpoints, you will find [chanfana D1 AutoEndpoints](https://chanfana.com/endpoints/auto/d1) and a [normal endpoint](https://chanfana.com/endpoints/defining-endpoints) to serve as examples for your projects. +- A [Cloudflare account](https://dash.cloudflare.com/sign-up) (free tier works) +- A domain with its DNS managed by Cloudflare +- A [Cloudflare API token](https://dash.cloudflare.com/profile/api-tokens) with **Zone > DNS > Edit** permission for your zone +- [Node.js](https://nodejs.org/) 18+ and [pnpm](https://pnpm.io/) -Besides being able to see the OpenAPI schema (openapi.json) in the browser, you can also extract the schema locally no hassle by running this command `npm run schema`. +## Setup - +### 1. Clone and install -> [!IMPORTANT] -> When using C3 to create this project, select "no" when it asks if you want to deploy. You need to follow this project's [setup steps](https://github.com/cloudflare/templates/tree/main/openapi-template#setup-steps) before deploying. +```sh +git clone https://github.com//cloudflare-ddns.git +cd cloudflare-ddns +pnpm install +``` + +### 2. Create the D1 database + +```sh +npx wrangler d1 create cloudflare-ddns-db +``` + +Copy the `database_id` from the output and paste it into `wrangler.jsonc` replacing the placeholder `00000000-0000-0000-0000-000000000000`. + +### 3. Set secrets + +```sh +npx wrangler secret put CF_API_TOKEN +npx wrangler secret put CF_ZONE_ID +npx wrangler secret put DDNS_SHARED_SECRET +npx wrangler secret put DDNS_ALLOWED_HOSTNAMES +``` + +| Secret | Description | +|---|---| +| `CF_API_TOKEN` | Cloudflare API token with DNS:Edit for your zone | +| `CF_ZONE_ID` | Zone ID (visible on your domain's overview page in the dashboard) | +| `DDNS_SHARED_SECRET` | A password you choose. Callers must send this to authenticate. | +| `DDNS_ALLOWED_HOSTNAMES` | Comma-separated hostnames this worker may update, e.g. `nas.example.com,home.example.com`. Wildcard companions such as `*.nas.example.com` are supported as explicit entries. | + +### 4. Deploy + +```sh +pnpm deploy +``` + +This runs D1 migrations automatically before deploying. + +## Environment variables + +These non-secret variables have defaults in `wrangler.jsonc` and can be overridden per-environment: + +| Variable | Default | Description | +|---|---|---| +| `DDNS_PROXIED` | `"false"` | Whether DNS records are proxied through Cloudflare. Most NAS setups need `"false"` (DNS-only) for direct IP access on non-standard ports. | +| `DDNS_TTL` | `"1"` | DNS record TTL in seconds. `"1"` means automatic. Valid range: 60-86400. | +| `DDNS_LOG_RETENTION_DAYS` | `"30"` | How many days of update logs to keep in D1. A cron job runs every 6 hours to prune older rows. | + +## Usage + +### Synology DSM -## Getting Started +In **Control Panel > External Access > DDNS > Customize**: -Outside of this repo, you can start a new project with this template using [C3](https://developers.cloudflare.com/pages/get-started/c3/) (the `create-cloudflare` CLI): +| Field | Value | +|---|---| +| Service Provider | Any name, e.g. `Cloudflare DDNS` | +| Query URL | `https://.workers.dev/nic/update?hostname=__HOSTNAME__&myip=__MYIP__&username=__USERNAME__&password=__PASSWORD__` | -```bash -npm create cloudflare@latest -- --template=cloudflare/templates/openapi-template +Then add a DDNS entry: + +| Field | Value | +|---|---| +| Service Provider | The custom provider you just created | +| Hostname | `nas.example.com` (must be in your allowed list) | +| Username | Anything (not used, but DSM requires a value) | +| Password | Your `DDNS_SHARED_SECRET` | + +DSM will call the worker whenever it detects an IP change. The worker responds with `good ` or `nochg ` on success. + +If you want one request for `nas.example.com` to also update `*.nas.example.com`, include both in `DDNS_ALLOWED_HOSTNAMES`, for example `nas.example.com,*.nas.example.com`. The worker treats the wildcard entry as a second managed DNS record and updates both records together. + +### JSON API + +```sh +curl -X POST https://.workers.dev/update \ + -H "Content-Type: application/json" \ + -H "X-DDNS-Secret: " \ + -d '{"hostname": "nas.example.com", "ip": "203.0.113.1"}' +``` + +Omit `ip` to use the caller's public IP (from Cloudflare's `CF-Connecting-IP` header). Omit `hostname` to default to the first hostname in your allowed list. + +Wildcard records work the same way here: if `DDNS_ALLOWED_HOSTNAMES` contains both `nas.example.com` and `*.nas.example.com`, a JSON update request for `nas.example.com` updates both records and returns per-target results in the response. + +OpenAPI documentation is served at the worker's root URL (`/`). + +### Health check + +``` +GET /health -> {"ok": true} ``` -A live public deployment of this template is available at [https://openapi-template.templates.workers.dev](https://openapi-template.templates.workers.dev) - -## Setup Steps - -1. Install the project dependencies with a package manager of your choice: - ```bash - npm install - ``` -2. Create a [D1 database](https://developers.cloudflare.com/d1/get-started/) with the name "openapi-template-db": - ```bash - npx wrangler d1 create openapi-template-db - ``` - ...and update the `database_id` field in `wrangler.json` with the new database ID. -3. Run the following db migration to initialize the database (notice the `migrations` directory in this project): - ```bash - npx wrangler d1 migrations apply DB --remote - ``` -4. Deploy the project! - ```bash - npx wrangler deploy - ``` -5. Monitor your worker - ```bash - npx wrangler tail - ``` - -## Testing - -This template includes integration tests using [Vitest](https://vitest.dev/). To run the tests locally: - -```bash -npm run test +## Development + +```sh +pnpm dev # starts wrangler dev with local D1 migrations +pnpm test # runs vitest with Miniflare ``` -Test files are located in the `tests/` directory, with examples demonstrating how to test your endpoints and database interactions. +## How it works + +1. The caller authenticates with a shared secret (query param or header). +2. The worker validates the hostname against the configured allowlist. +3. It resolves the IP from the request body/query, falling back to `CF-Connecting-IP`. +4. It resolves the request to one or more managed hostnames. For example, `nas.example.com` can fan out to both `nas.example.com` and `*.nas.example.com` when both are explicitly allowed. +5. It queries Cloudflare's DNS API for an existing A or AAAA record matching each target hostname. +6. If a target record already has the same IP, proxied state, and TTL, no change is made for that target (`nochg`). +7. Otherwise it creates or patches that target record (`good`). +8. The outcome is logged to D1 once per concrete DNS record (fire-and-forget, so the response is not delayed). +9. A cron job (every 6 hours) prunes log rows older than the retention period. + +## Wildcard notes + +- Cloudflare wildcard DNS records only wildcard the first label. `*.nas.example.com` is a wildcard record, but `subdomain.*.example.com` is not. +- Exact DNS records take precedence over wildcard records on Cloudflare. `nas.example.com` and `*.nas.example.com` are separate records with different jobs. +- This worker only auto-updates a wildcard companion when that wildcard record is explicitly present in `DDNS_ALLOWED_HOSTNAMES`. ## Project structure -1. Your main router is defined in `src/index.ts`. -2. Each endpoint has its own file in `src/endpoints/`. -3. Integration tests are located in the `tests/` directory. -4. For more information read the [chanfana documentation](https://chanfana.com/), [Hono documentation](https://hono.dev/docs), and [Vitest documentation](https://vitest.dev/guide/). +``` +src/ + index.ts Main app, route wiring, scheduled handler + types.ts DdnsEnv interface, response/action constants + ddns.ts Shared update logic (findRecord, compare, create/patch) + cloudflare-api.ts Typed wrapper around Cloudflare DNS REST API + validation.ts IP validation, config parsing (no dependencies) + logging.ts D1-backed audit logging and cleanup + endpoints/ + synology.ts GET /nic/update (DynDNS2 text responses) + update.ts POST /update (JSON, OpenAPI via Chanfana) + health.ts GET /health +migrations/ + 0001_ddns_logs.sql D1 table for update history +tests/ + helpers.ts Mock Cloudflare DNS API for tests + integration/ Integration tests (health, synology, update) +``` diff --git a/migrations/0001_add_tasks_table.sql b/migrations/0001_add_tasks_table.sql deleted file mode 100644 index 12254b8..0000000 --- a/migrations/0001_add_tasks_table.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Migration number: 0001 2025-06-19T18:13:02.648Z -CREATE TABLE IF NOT EXISTS tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - name TEXT NOT NULL, - slug TEXT NOT NULL, - description TEXT NOT NULL, - completed INTEGER NOT NULL, - due_date DATETIME NOT NULL -); diff --git a/migrations/0001_ddns_logs.sql b/migrations/0001_ddns_logs.sql new file mode 100644 index 0000000..f2a2db1 --- /dev/null +++ b/migrations/0001_ddns_logs.sql @@ -0,0 +1,17 @@ +-- Update history for DDNS record changes. +-- Each row captures one update attempt (success or failure) so operators +-- can audit what happened and debug Synology/cron misconfigurations. +-- A scheduled cron job prunes rows older than DDNS_LOG_RETENTION_DAYS. + +CREATE TABLE IF NOT EXISTS ddns_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + hostname TEXT NOT NULL, + record_type TEXT NOT NULL, -- 'A' or 'AAAA' + ip TEXT NOT NULL, + action TEXT NOT NULL, -- 'created', 'updated', 'noop', 'error' + error_message TEXT, -- NULL on success + source TEXT NOT NULL -- 'synology' or 'api' +); + +CREATE INDEX IF NOT EXISTS idx_ddns_logs_created_at ON ddns_logs (created_at); diff --git a/package.json b/package.json index e7a1314..47c8633 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,25 @@ { - "name": "chanfana-openapi-template", - "description": "Complete backend API template using Hono + Chanfana + D1 + Vitest.", - "cloudflare": { - "label": "Backend OpenAPI Template", - "products": [ - "Workers", - "D1" - ], - "categories": [ - "storage" - ], - "docs_url": "https://chanfana.com", - "preview_icon_url": "https://imagedelivery.net/wSMYJvS3Xw-n339CbDyDIA/1b2805d9-e7fb-4576-5176-6b409e251800/public", - "preview_image_url": "https://imagedelivery.net/wSMYJvS3Xw-n339CbDyDIA/cbde46cf-20fa-4548-7f70-1ae8c2f48900/preview", - "publish": true - }, + "name": "cloudflare-ddns", + "description": "Cloudflare Worker that acts as a DDNS provider for Synology NAS and other clients.", + "private": true, "dependencies": { - "chanfana": "2.8.3", - "hono": "4.11.1", - "zod": "3.25.67" + "chanfana": "^3.3.0", + "cloudflare": "^5.2.0", + "hono": "4.12.9", + "zod": "^4.3.6" }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "0.10.11", - "@types/node": "24.10.1", - "typescript": "5.9.3", - "vitest": "3.2.4", - "wrangler": "4.56.0" + "@cloudflare/vitest-pool-workers": "0.14.0", + "@types/node": "25.5.0", + "typescript": "6.0.2", + "vitest": "4.1.2", + "wrangler": "4.79.0" }, - "private": true, "scripts": { "cf-typegen": "wrangler types", "deploy": "wrangler deploy", - "dev": "pnpm seedLocalDb && wrangler dev", "predeploy": "wrangler d1 migrations apply DB --remote", - "schema": "npx chanfana", - "seedLocalDb": "wrangler d1 migrations apply DB --local", + "dev": "wrangler d1 migrations apply DB --local && wrangler dev", "test": "wrangler deploy --dry-run && npx vitest run --config tests/vitest.config.mts" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fd9fd2..d054bf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,123 +9,87 @@ importers: .: dependencies: chanfana: - specifier: 2.8.3 - version: 2.8.3 + specifier: ^3.3.0 + version: 3.3.0 + cloudflare: + specifier: ^5.2.0 + version: 5.2.0 hono: - specifier: 4.11.1 - version: 4.11.1 + specifier: 4.12.9 + version: 4.12.9 zod: - specifier: 3.25.67 - version: 3.25.67 + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@cloudflare/vitest-pool-workers': - specifier: 0.10.11 - version: 0.10.11(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@24.10.1)(yaml@2.8.3)) + specifier: 0.14.0 + version: 0.14.0(@vitest/runner@4.1.2)(@vitest/snapshot@4.1.2)(vitest@4.1.2(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3))) '@types/node': - specifier: 24.10.1 - version: 24.10.1 + specifier: 25.5.0 + version: 25.5.0 typescript: - specifier: 5.9.3 - version: 5.9.3 + specifier: 6.0.2 + version: 6.0.2 vitest: - specifier: 3.2.4 - version: 3.2.4(@types/node@24.10.1)(yaml@2.8.3) + specifier: 4.1.2 + version: 4.1.2(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3)) wrangler: - specifier: 4.56.0 - version: 4.56.0 + specifier: 4.79.0 + version: 4.79.0 packages: - '@asteasolutions/zod-to-openapi@7.3.4': - resolution: {integrity: sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==} + '@asteasolutions/zod-to-openapi@8.5.0': + resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==} peerDependencies: - zod: ^3.20.2 + zod: ^4.0.0 - '@cloudflare/kv-asset-handler@0.4.1': - resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==} + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} - '@cloudflare/unenv-preset@2.7.11': - resolution: {integrity: sha512-se23f1D4PxKrMKOq+Stz+Yn7AJ9ITHcEecXo2Yjb+UgbUDCEBch1FXQC6hx6uT5fNA3kmX3mfzeZiUmpK1W9IQ==} + '@cloudflare/unenv-preset@2.16.0': + resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==} peerDependencies: unenv: 2.0.0-rc.24 - workerd: ^1.20251106.1 + workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0 peerDependenciesMeta: workerd: optional: true - '@cloudflare/unenv-preset@2.7.13': - resolution: {integrity: sha512-NulO1H8R/DzsJguLC0ndMuk4Ufv0KSlN+E54ay9rn9ZCQo0kpAPwwh3LhgpZ96a3Dr6L9LqW57M4CqC34iLOvw==} + '@cloudflare/vitest-pool-workers@0.14.0': + resolution: {integrity: sha512-e1s3qW0YkUR8LDH0ytw94VjJzj/bLYR/4sJba6n2YlVsV9IyxeFrm4IDJtJrmm8xF9mCuZh+hUAtG8toCB385A==} peerDependencies: - unenv: 2.0.0-rc.24 - workerd: ^1.20251202.0 - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/vitest-pool-workers@0.10.11': - resolution: {integrity: sha512-p83iZIqIGysgJANtAqxn1o5t18sCW87waOP2/RUyS8ULUUnhyYqE4Fwaj33V405ApjTZAUOT5EhwrnlskNykeg==} - peerDependencies: - '@vitest/runner': 2.0.x - 3.2.x - '@vitest/snapshot': 2.0.x - 3.2.x - vitest: 2.0.x - 3.2.x + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 - '@cloudflare/workerd-darwin-64@1.20251125.0': - resolution: {integrity: sha512-xDIVJi8fPxBseRoEIzLiUJb0N+DXnah/ynS+Unzn58HEoKLetUWiV/T1Fhned//lo5krnToG9KRgVRs0SOOTpw==} + '@cloudflare/workerd-darwin-64@1.20260329.1': + resolution: {integrity: sha512-oyDXYlPBuGXKkZ85+M3jFz0/qYmvA4AEURN8USIGPDCR5q+HFSRwywSd9neTx3Wi7jhey2wuYaEpD3fEFWyWUA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-64@1.20251217.0': - resolution: {integrity: sha512-DN6vT+9ho61d/1/YuILW4VS+N1JBLaixWRL1vqNmhgbf8J8VHwWWotrRruEUYigJKx2yZyw6YsasE+yLXgx/Fw==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20251125.0': - resolution: {integrity: sha512-k5FQET5PXnWjeDqZUpl4Ah/Rn0bH6mjfUtTyeAy6ky7QB3AZpwIhgWQD0vOFB3OvJaK4J/K4cUtNChYXB9mY/A==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20251217.0': - resolution: {integrity: sha512-5nZOpRTkHmtcTc4Wbr1mj/O3dLb6aHZSiJuVBgtdbVcVmOXueSay3hnw1PXEyR+vpTKGUPkM+omUIslKHWnXDw==} + '@cloudflare/workerd-darwin-arm64@1.20260329.1': + resolution: {integrity: sha512-++ZxVa3ovzYeDLEG6zMqql9gzZAG8vak6ZSBQgprGKZp7akr+GKTpw9f3RrMP552NSi3gTisroLobrrkPBtYLQ==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20251125.0': - resolution: {integrity: sha512-at6n/FomkftykWx0EqVLUZ0juUFz3ORtEPeBbW9ZZ3BQEyfVUtYfdcz/f1cN8Yyb7TE9ovF071P0mBRkx83ODw==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-64@1.20251217.0': - resolution: {integrity: sha512-uoPGhMaZVXPpCsU0oG3HQzyVpXCGi5rU+jcHRjUI7DXM4EwctBGvZ380Knkja36qtl+ZvSKVR1pUFSGdK+45Pg==} + '@cloudflare/workerd-linux-64@1.20260329.1': + resolution: {integrity: sha512-kkeywAgIHwbqHkVILqbj/YkfbrA6ARbmutjiYzZA2MwMSfNXlw6/kedAKOY8YwcymZIgepx3YTIPnBP50pOotw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20251125.0': - resolution: {integrity: sha512-EiRn+jrNaIs1QveabXGHFoyn3s/l02ui6Yp3nssyNhtmtgviddtt8KObBfM1jQKjXTpZlunhwdN4Bxf4jhlOMw==} + '@cloudflare/workerd-linux-arm64@1.20260329.1': + resolution: {integrity: sha512-eYBN20+B7XOUSWEe0mlqkMUbfLoIKjKZnpqQiSxnLbL72JKY0D/KlfN/b7RVGLpewB7i8rTrwTNr0szCKnZzSQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20251217.0': - resolution: {integrity: sha512-ixHnHKsiz1Xko+eDgCJOZ7EEUZKtmnYq3AjW3nkVcLFypSLks4C29E45zVewdaN4wq8sCLeyQCl6r1kS17+DQQ==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20251125.0': - resolution: {integrity: sha512-6fdIsSeu65g++k8Y2DKzNKs0BkoU+KKI6GAAVBOLh2vvVWWnCP1OgMdVb5JAdjDrjDT5i0GSQu0bgQ8fPsW6zw==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workerd-windows-64@1.20251217.0': - resolution: {integrity: sha512-rP6USX+7ctynz3AtmKi+EvlLP3Xdr1ETrSdcnv693/I5QdUwBxq4yE1Lj6CV7GJizX6opXKYg8QMq0Q4eB9zRQ==} + '@cloudflare/workerd-windows-64@1.20260329.1': + resolution: {integrity: sha512-5R+/oxrDhS9nL3oA3ZWtD6ndMOqm7RfKknDNxLcmYW5DkUu7UH3J/s1t/Dz66iFePzr5BJmE7/8gbmve6TjtZQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -137,14 +101,8 @@ packages: '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} - '@esbuild/aix-ppc64@0.25.4': - resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.0': - resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -155,14 +113,8 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.4': - resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.0': - resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -173,14 +125,8 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.4': - resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.0': - resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -191,14 +137,8 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.4': - resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.0': - resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -209,14 +149,8 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.4': - resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.0': - resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -227,14 +161,8 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.4': - resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.0': - resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -245,14 +173,8 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.4': - resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.0': - resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -263,14 +185,8 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.4': - resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.0': - resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -281,14 +197,8 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.4': - resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.0': - resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -299,14 +209,8 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.4': - resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.0': - resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -317,14 +221,8 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.4': - resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.0': - resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -335,14 +233,8 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.4': - resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.0': - resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -353,14 +245,8 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.4': - resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.0': - resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -371,14 +257,8 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.4': - resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.0': - resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -389,14 +269,8 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.4': - resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.0': - resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -407,14 +281,8 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.4': - resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.0': - resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -425,14 +293,8 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.4': - resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.0': - resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -443,14 +305,8 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.4': - resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.27.0': - resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -461,14 +317,8 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.4': - resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.0': - resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -479,14 +329,8 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.4': - resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.27.0': - resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -497,14 +341,8 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.4': - resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.0': - resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -515,8 +353,8 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.0': - resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -527,14 +365,8 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.4': - resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.0': - resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -545,14 +377,8 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.4': - resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.0': - resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -563,14 +389,8 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.4': - resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.0': - resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -581,14 +401,8 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.4': - resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.0': - resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -599,119 +413,155 @@ packages: cpu: [x64] os: [win32] - '@img/sharp-darwin-arm64@0.33.5': - resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.33.5': - resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.0.4': - resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.0.4': - resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.0.4': - resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.0.5': - resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.0.4': - resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.0.4': - resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': - resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.0.4': - resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.33.5': - resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.33.5': - resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.33.5': - resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.33.5': - resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.33.5': - resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.33.5': - resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.33.5': - resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-ia32@0.33.5': - resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.33.5': - resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] @@ -880,6 +730,9 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -889,46 +742,51 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} - acorn-walk@8.3.2: - resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} - engines: {node: '>=0.4.0'} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} - acorn@8.14.0: - resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} - engines: {node: '>=0.4.0'} - hasBin: true + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -937,82 +795,78 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - birpc@0.2.14: - resolution: {integrity: sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chanfana@2.8.3: - resolution: {integrity: sha512-OBefbT8n+CeRYkC6lUoRgySTFbth+3LetxiNrCe0t+WgpQhhoIxPlo+lDYvNrLJgtsQr7XuPazm8yyxR8zwB7g==} + chanfana@3.3.0: + resolution: {integrity: sha512-YKZkjymihSi9Wiwb4BbdMzXH7j9OxT4KS+5b9Y9ZUsj6Wthfodkw81cgoSEOMvgMTicCryohKhKZSIKCA6GFQA==} + engines: {node: '>=18'} hasBin: true - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + cloudflare@5.2.0: + resolution: {integrity: sha512-dVzqDpPFYR9ApEC9e+JJshFJZXcw4HzM8W+3DHzO5oy9+8rLC53G7x6fEf9A7/gSuSCxuvndzui5qJKftfIM9A==} - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - - color@4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.6.4: - resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} - esbuild@0.25.4: - resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} - engines: {node: '>=18'} - hasBin: true + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} - esbuild@0.27.0: - resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true @@ -1024,8 +878,8 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - exit-hook@2.2.1: - resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} expect-type@1.3.0: @@ -1041,23 +895,55 @@ packages: picomatch: optional: true + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - hono@4.11.1: - resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==} - engines: {node: '>=16.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} - is-arrayish@0.3.4: - resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} @@ -1067,24 +953,23 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} - miniflare@4.20251125.0: - resolution: {integrity: sha512-xY6deLx0Drt8GfGG2Fv0fHUocHAIG/Iv62Kl36TPfDzgq7/+DQ5gYNisxnmyISQdA/sm7kOvn2XRBncxjWYrLg==} - engines: {node: '>=18.0.0'} - hasBin: true + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} - miniflare@4.20251217.0: - resolution: {integrity: sha512-8xsTQbPS6YV+ABZl9qiJIbsum6hbpbhqiyKpOVdzZrhK+1N8EFpT8R6aBZff7kezGmxYZSntjgjqTwJmj3JLgA==} + miniflare@4.20260329.0: + resolution: {integrity: sha512-+G+1YFVeuEpw/gZZmUHQR7IfzJV+DDGvnSl0yXzhgvHh8Nbr8Go5uiWIwl17EyZ1Uors3FKUMDUyU6+ejeKZOw==} engines: {node: '>=18.0.0'} hasBin: true @@ -1096,6 +981,23 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} @@ -1105,10 +1007,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1130,16 +1028,13 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.33.5: - resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - simple-swizzle@0.2.4: - resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1147,15 +1042,8 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - stoppable@1.1.0: - resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} - engines: {node: '>=4', npm: '>=6'} - - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} @@ -1164,48 +1052,42 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} - engines: {node: '>=14.0.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.14.0: - resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1246,26 +1128,33 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': optional: true '@vitest/ui': optional: true @@ -1274,38 +1163,32 @@ packages: jsdom: optional: true + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - workerd@1.20251125.0: - resolution: {integrity: sha512-oQYfgu3UZ15HlMcEyilKD1RdielRnKSG5MA0xoi1theVs99Rop9AEFYicYCyK1R4YjYblLRYEiL1tMgEFqpReA==} - engines: {node: '>=16'} - hasBin: true - - workerd@1.20251217.0: - resolution: {integrity: sha512-s3mHDSWwHTduyY8kpHOsl27ZJ4ziDBJlc18PfBvNMqNnhO7yBeemlxH7bo7yQyU1foJrIZ6IENHDDg0Z9N8zQA==} + workerd@1.20260329.1: + resolution: {integrity: sha512-+ifMv3uBuD33ee7pan5n8+sgVxm2u5HnbgfXzHKwMNTKw86znqBJSnJoBqtP88+2T5U2Lu11xXUt+khPYioXwQ==} engines: {node: '>=16'} hasBin: true - wrangler@4.51.0: - resolution: {integrity: sha512-JHv+58UxM2//e4kf9ASDwg016xd/OdDNDUKW6zLQyE7Uc9ayYKX1QJ9NsYtpo4dC1dfg6rT67pf1aNK1cTzUDg==} - engines: {node: '>=20.0.0'} + wrangler@4.79.0: + resolution: {integrity: sha512-NMinIdB1pXIqdk+NLw4+RjzB7K5z4+lWMxhTxFTfZomwJu3Pm6N+kZ+a66D3nI7w0oCjsdv/umrZVmSHCBp2cg==} + engines: {node: '>=20.3.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20251125.0 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - wrangler@4.56.0: - resolution: {integrity: sha512-Nqi8duQeRbA+31QrD6QlWHW3IZVnuuRxMy7DEg46deUzywivmaRV/euBN5KKXDPtA24VyhYsK7I0tkb7P5DM2w==} - engines: {node: '>=20.0.0'} - deprecated: Version 4.55.0 and 4.56.0 can incorrectly automatically delegate 'wrangler deploy' to 'opennextjs-cloudflare'. Use an older or newer version. - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20251217.0 + '@cloudflare/workers-types': ^4.20260329.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -1337,80 +1220,55 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - zod@3.22.3: - resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@3.25.67: - resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} snapshots: - '@asteasolutions/zod-to-openapi@7.3.4(zod@3.25.67)': + '@asteasolutions/zod-to-openapi@8.5.0(zod@4.3.6)': dependencies: openapi3-ts: 4.5.0 - zod: 3.25.67 + zod: 4.3.6 - '@cloudflare/kv-asset-handler@0.4.1': - dependencies: - mime: 3.0.0 - - '@cloudflare/unenv-preset@2.7.11(unenv@2.0.0-rc.24)(workerd@1.20251125.0)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20251125.0 + '@cloudflare/kv-asset-handler@0.4.2': {} - '@cloudflare/unenv-preset@2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251217.0)': + '@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260329.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20251217.0 + workerd: 1.20260329.1 - '@cloudflare/vitest-pool-workers@0.10.11(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4(@types/node@24.10.1)(yaml@2.8.3))': + '@cloudflare/vitest-pool-workers@0.14.0(@vitest/runner@4.1.2)(@vitest/snapshot@4.1.2)(vitest@4.1.2(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3)))': dependencies: - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - birpc: 0.2.14 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 cjs-module-lexer: 1.4.3 - devalue: 5.6.4 - miniflare: 4.20251125.0 - semver: 7.7.4 - vitest: 3.2.4(@types/node@24.10.1)(yaml@2.8.3) - wrangler: 4.51.0 - zod: 3.25.67 + esbuild: 0.27.3 + miniflare: 4.20260329.0 + vitest: 4.1.2(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3)) + wrangler: 4.79.0 + zod: 3.25.76 transitivePeerDependencies: - '@cloudflare/workers-types' - bufferutil - utf-8-validate - '@cloudflare/workerd-darwin-64@1.20251125.0': + '@cloudflare/workerd-darwin-64@1.20260329.1': optional: true - '@cloudflare/workerd-darwin-64@1.20251217.0': + '@cloudflare/workerd-darwin-arm64@1.20260329.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20251125.0': + '@cloudflare/workerd-linux-64@1.20260329.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20251217.0': + '@cloudflare/workerd-linux-arm64@1.20260329.1': optional: true - '@cloudflare/workerd-linux-64@1.20251125.0': - optional: true - - '@cloudflare/workerd-linux-64@1.20251217.0': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20251125.0': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20251217.0': - optional: true - - '@cloudflare/workerd-windows-64@1.20251125.0': - optional: true - - '@cloudflare/workerd-windows-64@1.20251217.0': + '@cloudflare/workerd-windows-64@1.20260329.1': optional: true '@cspotcode/source-map-support@0.8.1': @@ -1422,310 +1280,256 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.25.4': - optional: true - - '@esbuild/aix-ppc64@0.27.0': + '@esbuild/aix-ppc64@0.27.3': optional: true '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/android-arm64@0.25.4': - optional: true - - '@esbuild/android-arm64@0.27.0': + '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/android-arm@0.25.4': - optional: true - - '@esbuild/android-arm@0.27.0': + '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/android-x64@0.25.4': - optional: true - - '@esbuild/android-x64@0.27.0': + '@esbuild/android-x64@0.27.3': optional: true '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.25.4': - optional: true - - '@esbuild/darwin-arm64@0.27.0': + '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/darwin-x64@0.25.4': - optional: true - - '@esbuild/darwin-x64@0.27.0': + '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.25.4': - optional: true - - '@esbuild/freebsd-arm64@0.27.0': + '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.25.4': - optional: true - - '@esbuild/freebsd-x64@0.27.0': + '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/linux-arm64@0.25.4': - optional: true - - '@esbuild/linux-arm64@0.27.0': + '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-arm@0.25.4': - optional: true - - '@esbuild/linux-arm@0.27.0': + '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-ia32@0.25.4': - optional: true - - '@esbuild/linux-ia32@0.27.0': + '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-loong64@0.25.4': - optional: true - - '@esbuild/linux-loong64@0.27.0': + '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-mips64el@0.25.4': - optional: true - - '@esbuild/linux-mips64el@0.27.0': + '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-ppc64@0.25.4': - optional: true - - '@esbuild/linux-ppc64@0.27.0': + '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.25.4': - optional: true - - '@esbuild/linux-riscv64@0.27.0': + '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-s390x@0.25.4': - optional: true - - '@esbuild/linux-s390x@0.27.0': + '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-x64@0.25.4': - optional: true - - '@esbuild/linux-x64@0.27.0': + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.25.4': - optional: true - - '@esbuild/netbsd-arm64@0.27.0': + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.25.4': - optional: true - - '@esbuild/netbsd-x64@0.27.0': + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.25.4': - optional: true - - '@esbuild/openbsd-arm64@0.27.0': + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.25.4': - optional: true - - '@esbuild/openbsd-x64@0.27.0': + '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/openharmony-arm64@0.27.0': + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/sunos-x64@0.25.4': + '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/sunos-x64@0.27.0': + '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/sunos-x64@0.27.4': + '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-arm64@0.25.4': + '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/win32-arm64@0.27.0': + '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-arm64@0.27.4': + '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/win32-ia32@0.25.4': + '@esbuild/win32-x64@0.27.3': optional: true - '@esbuild/win32-ia32@0.27.0': + '@esbuild/win32-x64@0.27.4': optional: true - '@esbuild/win32-ia32@0.27.4': + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@esbuild/win32-x64@0.25.4': + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true - '@esbuild/win32-x64@0.27.0': + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true - '@esbuild/win32-x64@0.27.4': + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true - '@img/sharp-darwin-arm64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-darwin-x64@0.33.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-darwin-arm64@1.0.4': + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.0.4': + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.0.4': + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.0.5': + '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linux-s390x@1.0.4': + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.0.4': + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true - '@img/sharp-libvips-linuxmusl-x64@1.0.4': + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-arm64@0.33.5': + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@img/sharp-linux-arm@0.33.5': + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@img/sharp-linux-s390x@0.33.5': + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linux-x64@0.33.5': + '@img/sharp-linux-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@img/sharp-linuxmusl-arm64@0.33.5': + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@img/sharp-linuxmusl-x64@0.33.5': + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true - '@img/sharp-wasm32@0.33.5': + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.9.1 optional: true - '@img/sharp-win32-ia32@0.33.5': + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': optional: true - '@img/sharp-win32-x64@0.33.5': + '@img/sharp-win32-x64@0.34.5': optional: true '@jridgewell/resolve-uri@3.1.2': {} @@ -1828,6 +1632,8 @@ snapshots: '@speed-highlight/core@1.2.15': {} + '@standard-schema/spec@1.1.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1837,174 +1643,170 @@ snapshots: '@types/estree@1.0.8': {} - '@types/node@24.10.1': + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 25.5.0 + form-data: 4.0.5 + + '@types/node@18.19.130': dependencies: - undici-types: 7.16.0 + undici-types: 5.26.5 - '@vitest/expect@3.2.4': + '@types/node@25.5.0': dependencies: + undici-types: 7.18.2 + + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.10.1)(yaml@2.8.3) + vite: 7.3.1(@types/node@25.5.0)(yaml@2.8.3) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.1.2': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 - '@vitest/runner@3.2.4': + '@vitest/runner@4.1.2': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.1.2 pathe: 2.0.3 - strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.1.2': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 + '@vitest/spy@4.1.2': {} - '@vitest/utils@3.2.4': + '@vitest/utils@4.1.2': dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 - acorn-walk@8.3.2: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 - acorn@8.14.0: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 argparse@2.0.1: {} assertion-error@2.0.1: {} - birpc@0.2.14: {} + asynckit@0.4.0: {} blake3-wasm@2.1.5: {} - cac@6.7.14: {} - - chai@5.3.3: + call-bind-apply-helpers@1.0.2: dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 + es-errors: 1.3.0 + function-bind: 1.1.2 + + chai@6.2.2: {} - chanfana@2.8.3: + chanfana@3.3.0: dependencies: - '@asteasolutions/zod-to-openapi': 7.3.4(zod@3.25.67) + '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.3.6) js-yaml: 4.1.1 openapi3-ts: 4.5.0 yargs-parser: 22.0.0 - zod: 3.25.67 - - check-error@2.1.3: {} + zod: 4.3.6 cjs-module-lexer@1.4.3: {} - color-convert@2.0.1: + cloudflare@5.2.0: dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding - color-string@1.9.1: + combined-stream@1.0.8: dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.4 + delayed-stream: 1.0.0 - color@4.2.3: - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 + convert-source-map@2.0.0: {} cookie@1.1.1: {} - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@5.0.2: {} + delayed-stream@1.0.0: {} detect-libc@2.1.2: {} - devalue@5.6.4: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 error-stack-parser-es@1.0.5: {} - es-module-lexer@1.7.0: {} + es-define-property@1.0.1: {} - esbuild@0.25.4: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.4 - '@esbuild/android-arm': 0.25.4 - '@esbuild/android-arm64': 0.25.4 - '@esbuild/android-x64': 0.25.4 - '@esbuild/darwin-arm64': 0.25.4 - '@esbuild/darwin-x64': 0.25.4 - '@esbuild/freebsd-arm64': 0.25.4 - '@esbuild/freebsd-x64': 0.25.4 - '@esbuild/linux-arm': 0.25.4 - '@esbuild/linux-arm64': 0.25.4 - '@esbuild/linux-ia32': 0.25.4 - '@esbuild/linux-loong64': 0.25.4 - '@esbuild/linux-mips64el': 0.25.4 - '@esbuild/linux-ppc64': 0.25.4 - '@esbuild/linux-riscv64': 0.25.4 - '@esbuild/linux-s390x': 0.25.4 - '@esbuild/linux-x64': 0.25.4 - '@esbuild/netbsd-arm64': 0.25.4 - '@esbuild/netbsd-x64': 0.25.4 - '@esbuild/openbsd-arm64': 0.25.4 - '@esbuild/openbsd-x64': 0.25.4 - '@esbuild/sunos-x64': 0.25.4 - '@esbuild/win32-arm64': 0.25.4 - '@esbuild/win32-ia32': 0.25.4 - '@esbuild/win32-x64': 0.25.4 - - esbuild@0.27.0: + es-errors@1.3.0: {} + + es-module-lexer@2.0.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.0 - '@esbuild/android-arm': 0.27.0 - '@esbuild/android-arm64': 0.27.0 - '@esbuild/android-x64': 0.27.0 - '@esbuild/darwin-arm64': 0.27.0 - '@esbuild/darwin-x64': 0.27.0 - '@esbuild/freebsd-arm64': 0.27.0 - '@esbuild/freebsd-x64': 0.27.0 - '@esbuild/linux-arm': 0.27.0 - '@esbuild/linux-arm64': 0.27.0 - '@esbuild/linux-ia32': 0.27.0 - '@esbuild/linux-loong64': 0.27.0 - '@esbuild/linux-mips64el': 0.27.0 - '@esbuild/linux-ppc64': 0.27.0 - '@esbuild/linux-riscv64': 0.27.0 - '@esbuild/linux-s390x': 0.27.0 - '@esbuild/linux-x64': 0.27.0 - '@esbuild/netbsd-arm64': 0.27.0 - '@esbuild/netbsd-x64': 0.27.0 - '@esbuild/openbsd-arm64': 0.27.0 - '@esbuild/openbsd-x64': 0.27.0 - '@esbuild/openharmony-arm64': 0.27.0 - '@esbuild/sunos-x64': 0.27.0 - '@esbuild/win32-arm64': 0.27.0 - '@esbuild/win32-ia32': 0.27.0 - '@esbuild/win32-x64': 0.27.0 + '@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 esbuild@0.27.4: optionalDependencies: @@ -2039,7 +1841,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 - exit-hook@2.2.1: {} + event-target-shim@5.0.1: {} expect-type@1.3.0: {} @@ -2047,16 +1849,61 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + form-data-encoder@1.7.2: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + fsevents@2.3.3: optional: true - glob-to-regexp@0.4.1: {} + function-bind@1.1.2: {} - hono@4.11.1: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 - is-arrayish@0.3.4: {} + gopd@1.2.0: {} - js-tokens@9.0.1: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.9: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 js-yaml@4.1.1: dependencies: @@ -2064,46 +1911,26 @@ snapshots: kleur@4.1.5: {} - loupe@3.2.1: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - mime@3.0.0: {} + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} - miniflare@4.20251125.0: + mime-types@2.1.35: 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 - sharp: 0.33.5 - stoppable: 1.1.0 - undici: 7.14.0 - workerd: 1.20251125.0 - ws: 8.18.0 - youch: 4.1.0-beta.10 - zod: 3.22.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + mime-db: 1.52.0 - miniflare@4.20251217.0: + miniflare@4.20260329.0: 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 - sharp: 0.33.5 - stoppable: 1.1.0 - undici: 7.14.0 - workerd: 1.20251217.0 + sharp: 0.34.5 + undici: 7.24.4 + workerd: 1.20260329.1 ws: 8.18.0 youch: 4.1.0-beta.10 - zod: 3.22.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -2112,6 +1939,14 @@ snapshots: nanoid@3.3.11: {} + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + obug@2.1.1: {} + openapi3-ts@4.5.0: dependencies: yaml: 2.8.3 @@ -2120,8 +1955,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -2165,102 +1998,76 @@ snapshots: semver@7.7.4: {} - sharp@0.33.5: + sharp@0.34.5: dependencies: - color: 4.2.3 + '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.7.4 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 + '@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 siginfo@2.0.0: {} - simple-swizzle@0.2.4: - dependencies: - is-arrayish: 0.3.4 - source-map-js@1.2.1: {} stackback@0.0.2: {} - std-env@3.10.0: {} - - stoppable@1.1.0: {} - - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 + std-env@4.0.0: {} supports-color@10.2.2: {} tinybench@2.9.0: {} - tinyexec@0.3.2: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} - tinyspy@4.0.4: {} + tr46@0.0.3: {} tslib@2.8.1: optional: true - typescript@5.9.3: {} + typescript@6.0.2: {} + + undici-types@5.26.5: {} - undici-types@7.16.0: {} + undici-types@7.18.2: {} - undici@7.14.0: {} + undici@7.24.4: {} unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 - vite-node@3.2.4(@types/node@24.10.1)(yaml@2.8.3): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.1(@types/node@24.10.1)(yaml@2.8.3) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite@7.3.1(@types/node@24.10.1)(yaml@2.8.3): + vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -2269,98 +2076,69 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.5.0 fsevents: 2.3.3 yaml: 2.8.3 - vitest@3.2.4(@types/node@24.10.1)(yaml@2.8.3): + vitest@4.1.2(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3)): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.1)(yaml@2.8.3)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@25.5.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.10.1)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@24.10.1)(yaml@2.8.3) + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.5.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.5.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml + + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - workerd@1.20251125.0: + workerd@1.20260329.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20251125.0 - '@cloudflare/workerd-darwin-arm64': 1.20251125.0 - '@cloudflare/workerd-linux-64': 1.20251125.0 - '@cloudflare/workerd-linux-arm64': 1.20251125.0 - '@cloudflare/workerd-windows-64': 1.20251125.0 - - workerd@1.20251217.0: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20251217.0 - '@cloudflare/workerd-darwin-arm64': 1.20251217.0 - '@cloudflare/workerd-linux-64': 1.20251217.0 - '@cloudflare/workerd-linux-arm64': 1.20251217.0 - '@cloudflare/workerd-windows-64': 1.20251217.0 - - wrangler@4.51.0: - dependencies: - '@cloudflare/kv-asset-handler': 0.4.1 - '@cloudflare/unenv-preset': 2.7.11(unenv@2.0.0-rc.24)(workerd@1.20251125.0) - blake3-wasm: 2.1.5 - esbuild: 0.25.4 - miniflare: 4.20251125.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20251125.0 - optionalDependencies: - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate + '@cloudflare/workerd-darwin-64': 1.20260329.1 + '@cloudflare/workerd-darwin-arm64': 1.20260329.1 + '@cloudflare/workerd-linux-64': 1.20260329.1 + '@cloudflare/workerd-linux-arm64': 1.20260329.1 + '@cloudflare/workerd-windows-64': 1.20260329.1 - wrangler@4.56.0: + wrangler@4.79.0: dependencies: - '@cloudflare/kv-asset-handler': 0.4.1 - '@cloudflare/unenv-preset': 2.7.13(unenv@2.0.0-rc.24)(workerd@1.20251217.0) + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260329.1) blake3-wasm: 2.1.5 - esbuild: 0.27.0 - miniflare: 4.20251217.0 + esbuild: 0.27.3 + miniflare: 4.20260329.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20251217.0 + workerd: 1.20260329.1 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: @@ -2386,6 +2164,6 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 - zod@3.22.3: {} + zod@3.25.76: {} - zod@3.25.67: {} + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..661850f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true + sharp: true + workerd: true diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..b107f6c --- /dev/null +++ b/schema.json @@ -0,0 +1,199 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Cloudflare DDNS", + "version": "1.0.0", + "description": "A Cloudflare Worker that acts as a DDNS provider. Synology NAS devices (and other clients) call this service to keep DNS records in sync with a changing public IP address." + }, + "components": { + "schemas": {}, + "parameters": {} + }, + "paths": { + "/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health check", + "operationId": "get_HealthEndpoint", + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "ok" + ] + } + } + } + } + } + } + }, + "/update": { + "post": { + "tags": [ + "DDNS" + ], + "summary": "Update a DNS record with the caller's current IP", + "description": "Authenticate with X-DDNS-Secret header. Optionally provide hostname and ip in the JSON body; omitted values are inferred from config and the request's CF-Connecting-IP header.", + "operationId": "post_UpdateEndpoint", + "parameters": [ + { + "schema": { + "type": "string", + "description": "Shared secret for authentication" + }, + "required": true, + "description": "Shared secret for authentication", + "name": "X-DDNS-Secret", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "ip": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "DNS record update result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "action": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "record_type": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "record_id": { + "type": "string" + } + }, + "required": [ + "ok", + "action", + "hostname", + "record_type", + "ip" + ] + } + } + } + }, + "400": { + "description": "Bad request (missing IP or invalid hostname)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + } + } + }, + "required": [ + "success", + "errors" + ] + } + } + } + }, + "401": { + "description": "Authentication failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "number" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] + } + } + }, + "required": [ + "success", + "errors" + ] + } + } + } + } + } + } + } + }, + "webhooks": {} +} \ No newline at end of file diff --git a/src/cloudflare-api.ts b/src/cloudflare-api.ts new file mode 100644 index 0000000..cad6245 --- /dev/null +++ b/src/cloudflare-api.ts @@ -0,0 +1,129 @@ +/** + * DNS record operations backed by the official Cloudflare TypeScript SDK. + * + * All functions accept the env bindings directly so they can read + * `CF_API_TOKEN` and `CF_ZONE_ID` without global state. + */ + +import Cloudflare from "cloudflare"; + +import type { DdnsEnv } from "./types"; + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +/** + * Simplified DNS record shape used throughout this worker. + * Mapped from the SDK's discriminated `RecordResponse` union. + */ +export interface DnsRecord { + id: string; + type: string; + name: string; + content: string; + proxied?: boolean; + ttl?: number; +} + +// --------------------------------------------------------------------------- +// DNS record operations +// --------------------------------------------------------------------------- + +function createClient(env: Pick): Cloudflare { + return new Cloudflare({ + apiToken: env.CF_API_TOKEN, + // Force the SDK through the runtime fetch so integration tests can stub + // outbound API calls by replacing globalThis.fetch. + fetch: (input, init) => globalThis.fetch(input, init), + }); +} + +/** + * Find an existing DNS record by exact hostname and type (A or AAAA). + * + * Uses the SDK's list method with server-side `type` and `name` filters. + * Returns `null` when no matching record exists. + */ +export async function findRecord( + env: Pick, + hostname: string, + type: "A" | "AAAA", +): Promise { + const client = createClient(env); + for await (const r of client.dns.records.list({ + zone_id: env.CF_ZONE_ID, + type, + name: { exact: hostname }, + })) { + if (r.type === "A" || r.type === "AAAA") { + return { id: r.id, type: r.type, name: r.name, content: r.content ?? "", proxied: r.proxied, ttl: r.ttl }; + } + } + return null; +} + +/** + * Create a new DNS record in the zone. + * Returns the created record including its Cloudflare-assigned `id`. + */ +export async function createRecord( + env: Pick, + payload: { + type: "A" | "AAAA"; + name: string; + content: string; + ttl: number; + proxied: boolean; + comment: string; + }, +): Promise { + const client = createClient(env); + const base = { + zone_id: env.CF_ZONE_ID, + name: payload.name, + content: payload.content, + ttl: payload.ttl, + proxied: payload.proxied, + comment: payload.comment, + }; + const r = + payload.type === "A" + ? await client.dns.records.create({ ...base, type: "A" }) + : await client.dns.records.create({ ...base, type: "AAAA" }); + if (r.type !== "A" && r.type !== "AAAA") throw new Error(`Unexpected record type: ${r.type}`); + return { id: r.id, type: r.type, name: r.name, content: r.content ?? "", proxied: r.proxied, ttl: r.ttl }; +} + +/** + * Overwrite an existing DNS record (PUT). + * Returns the updated record. + */ +export async function updateRecord( + env: Pick, + recordId: string, + payload: { + type: "A" | "AAAA"; + name: string; + content: string; + ttl: number; + proxied: boolean; + comment: string; + }, +): Promise { + const client = createClient(env); + const base = { + zone_id: env.CF_ZONE_ID, + name: payload.name, + content: payload.content, + ttl: payload.ttl, + proxied: payload.proxied, + comment: payload.comment, + }; + const r = + payload.type === "A" + ? await client.dns.records.update(recordId, { ...base, type: "A" }) + : await client.dns.records.update(recordId, { ...base, type: "AAAA" }); + if (r.type !== "A" && r.type !== "AAAA") throw new Error(`Unexpected record type: ${r.type}`); + return { id: r.id, type: r.type, name: r.name, content: r.content ?? "", proxied: r.proxied, ttl: r.ttl }; +} diff --git a/src/ddns.ts b/src/ddns.ts new file mode 100644 index 0000000..04d09c5 --- /dev/null +++ b/src/ddns.ts @@ -0,0 +1,163 @@ +/** + * Shared DDNS update logic used by both the Synology-compatible GET + * endpoint and the JSON POST endpoint. + * + * Callers resolve authentication, hostname, and IP before calling + * `performDdnsUpdate`. This module handles the Cloudflare DNS API + * interaction and returns a discriminated result that each endpoint + * can translate to its own response format. + */ + +import type { DdnsEnv } from "./types"; +import { UPDATE_ACTION } from "./types"; +import { createRecord, findRecord, updateRecord } from "./cloudflare-api"; +import { detectRecordType, parseBoolean, parseTtl } from "./validation"; + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +export interface DdnsUpdateSuccess { + action: "created" | "updated" | "noop"; + ip: string; + record_type: "A" | "AAAA"; + record_id?: string; +} + +export interface DdnsUpdateError { + action: "error"; + ip: string; + record_type: "A" | "AAAA"; + message: string; +} + +export type DdnsUpdateResult = DdnsUpdateSuccess | DdnsUpdateError; + +export type DdnsTargetUpdateResult = + | ({ hostname: string } & DdnsUpdateSuccess) + | ({ hostname: string } & DdnsUpdateError); + +export interface DdnsBatchUpdateResult { + action: "created" | "updated" | "noop" | "error"; + ip: string; + record_type: "A" | "AAAA"; + record_id?: string; + results: DdnsTargetUpdateResult[]; +} + +// --------------------------------------------------------------------------- +// Core update +// --------------------------------------------------------------------------- + +/** + * Look up, compare, and (if needed) create or patch the DNS record for + * `hostname` so it points at `ip`. + * + * Returns a result the caller can map to its response format. Never + * throws; Cloudflare API errors are caught and returned as + * `{ action: "error" }`. + */ +export async function performDdnsUpdate( + env: DdnsEnv, + hostname: string, + ip: string, +): Promise { + const recordType = detectRecordType(ip); + const proxied = parseBoolean(env.DDNS_PROXIED, false); + const ttl = parseTtl(env.DDNS_TTL); + const comment = "managed-by:cloudflare-ddns"; + + try { + const existing = await findRecord(env, hostname, recordType); + + if ( + existing && + existing.content === ip && + Boolean(existing.proxied) === proxied && + (existing.ttl ?? 1) === ttl + ) { + return { + action: UPDATE_ACTION.NOOP, + ip, + record_type: recordType, + record_id: existing.id, + }; + } + + const payload = { type: recordType, name: hostname, content: ip, ttl, proxied, comment }; + + if (existing) { + const updated = await updateRecord(env, existing.id, payload); + return { + action: UPDATE_ACTION.UPDATED, + ip, + record_type: recordType, + record_id: updated.id, + }; + } + + const created = await createRecord(env, payload); + return { + action: UPDATE_ACTION.CREATED, + ip, + record_type: recordType, + record_id: created.id, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { action: UPDATE_ACTION.ERROR, ip, record_type: recordType, message }; + } +} + +function summarizeBatchAction(results: DdnsTargetUpdateResult[]): DdnsBatchUpdateResult["action"] { + if (results.some((result) => result.action === UPDATE_ACTION.ERROR)) { + return UPDATE_ACTION.ERROR; + } + + if (results.every((result) => result.action === UPDATE_ACTION.NOOP)) { + return UPDATE_ACTION.NOOP; + } + + if (results.length === 1) { + return results[0].action; + } + + if (results.every((result) => result.action === UPDATE_ACTION.CREATED)) { + return UPDATE_ACTION.CREATED; + } + + return UPDATE_ACTION.UPDATED; +} + +/** + * Update one or more DNS records for the same IP address. + * + * Each hostname is updated independently. The batch result reports per-target + * outcomes plus a summarized top-level action for the request. + */ +export async function performDdnsUpdates( + env: DdnsEnv, + hostnames: string[], + ip: string, +): Promise { + const recordType = detectRecordType(ip); + const results = await Promise.all( + hostnames.map(async (hostname) => ({ + hostname, + ...(await performDdnsUpdate(env, hostname, ip)), + })), + ); + + const firstSuccessfulResult = results.find((result) => result.action !== UPDATE_ACTION.ERROR); + + return { + action: summarizeBatchAction(results), + ip, + record_type: recordType, + record_id: + firstSuccessfulResult && "record_id" in firstSuccessfulResult + ? firstSuccessfulResult.record_id + : undefined, + results, + }; +} diff --git a/src/endpoints/dummyEndpoint.ts b/src/endpoints/dummyEndpoint.ts deleted file mode 100644 index 18c3a32..0000000 --- a/src/endpoints/dummyEndpoint.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { contentJson, OpenAPIRoute } from "chanfana"; -import { AppContext } from "../types"; -import { z } from "zod"; - -export class DummyEndpoint extends OpenAPIRoute { - public schema = { - tags: ["Dummy"], - summary: "this endpoint is an example", - operationId: "example-endpoint", // This is optional - request: { - params: z.object({ - slug: z.string(), - }), - body: contentJson( - z.object({ - name: z.string(), - }), - ), - }, - responses: { - "200": { - description: "Returns the log details", - ...contentJson({ - success: Boolean, - result: z.object({ - msg: z.string(), - slug: z.string(), - name: z.string(), - }), - }), - }, - }, - }; - - public async handle(c: AppContext) { - const data = await this.getValidatedData(); - - return { - success: true, - result: { - msg: "this is a dummy endpoint, serving as example", - slug: data.params.slug, - name: data.body.name, - }, - }; - } -} diff --git a/src/endpoints/health.ts b/src/endpoints/health.ts new file mode 100644 index 0000000..91ab2e8 --- /dev/null +++ b/src/endpoints/health.ts @@ -0,0 +1,19 @@ +import { contentJson, OpenAPIRoute } from "chanfana"; +import { z } from "zod"; + +export class HealthEndpoint extends OpenAPIRoute { + schema = { + tags: ["System"], + summary: "Health check", + responses: { + "200": { + description: "Service is healthy", + ...contentJson(z.object({ ok: z.literal(true) })), + }, + }, + }; + + async handle() { + return { ok: true as const }; + } +} diff --git a/src/endpoints/synology.ts b/src/endpoints/synology.ts new file mode 100644 index 0000000..46bd07d --- /dev/null +++ b/src/endpoints/synology.ts @@ -0,0 +1,113 @@ +/** + * Synology-compatible DDNS endpoint. + * + * Synology DSM's "Customize a DDNS service provider" dialog builds a GET + * request with these template variables substituted into the query string: + * + * __HOSTNAME__ → hostname + * __MYIP__ → myip + * __USERNAME__ → username + * __PASSWORD__ → password + * + * The response body must be a DynDNS2-style status keyword that DSM can + * parse: `good `, `nochg `, `badauth`, `nohost`, or `911`. + */ + +import type { AppContext, DdnsEnv } from "../types"; +import { DDNS_RESPONSE, UPDATE_ACTION } from "../types"; +import { performDdnsUpdates } from "../ddns"; +import { logUpdate } from "../logging"; +import { isIpv4, isIpv6, parseAllowedHostnames, resolveUpdateHostnames } from "../validation"; + +/** + * Pick the best available IP address. + * + * Synology sends the NAS's detected public IP in the `myip` query param. + * If that is missing or invalid, fall back to `CF-Connecting-IP` which + * Cloudflare sets from the TCP connection. + */ +function pickIp(myip: string | null, cfConnectingIp: string | null): string | null { + if (myip && (isIpv4(myip) || isIpv6(myip))) return myip; + if (cfConnectingIp && (isIpv4(cfConnectingIp) || isIpv6(cfConnectingIp))) return cfConnectingIp; + return null; +} + +/** Plain-text response with `no-store` caching. */ +function ddns(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }, + }); +} + +/** + * Handle `GET /nic/update` for Synology DSM custom DDNS providers. + * + * Query URL configured in DSM: + * ``` + * https://.workers.dev/nic/update?hostname=__HOSTNAME__&myip=__MYIP__&username=__USERNAME__&password=__PASSWORD__ + * ``` + */ +export async function handleSynologyUpdate(c: AppContext): Promise { + const env: DdnsEnv = c.env; + + try { + const hostname = (c.req.query("hostname") ?? "").trim().toLowerCase(); + const myip = (c.req.query("myip") ?? "").trim(); + const password = (c.req.query("password") ?? "").trim(); + + // Authentication: shared secret must match. + if (!password || password !== env.DDNS_SHARED_SECRET) { + return ddns(DDNS_RESPONSE.BADAUTH); + } + + // Hostname must be in the allowed list. + const allowed = parseAllowedHostnames(env.DDNS_ALLOWED_HOSTNAMES); + const targetHostnames = resolveUpdateHostnames(hostname, allowed); + if (!hostname || targetHostnames.length === 0) { + return ddns(DDNS_RESPONSE.NOHOST); + } + + // Resolve the client IP: prefer the explicit myip param, fall back to + // CF-Connecting-IP from the TCP connection. + const ip = pickIp(myip || null, c.req.header("CF-Connecting-IP") ?? null); + if (!ip) { + return ddns(DDNS_RESPONSE.SERVER_ERROR, 500); + } + + const result = await performDdnsUpdates(env, targetHostnames, ip); + + // Log the outcome (fire-and-forget via waitUntil so the response + // is not delayed by D1 writes). + c.executionCtx.waitUntil( + Promise.all( + result.results.map((targetResult) => + logUpdate(env.DB, { + hostname: targetResult.hostname, + record_type: targetResult.record_type, + ip: targetResult.ip, + action: targetResult.action, + error_message: + targetResult.action === UPDATE_ACTION.ERROR ? targetResult.message : null, + source: "synology", + }), + ), + ), + ); + + if (result.action === UPDATE_ACTION.ERROR) { + return ddns(DDNS_RESPONSE.SERVER_ERROR, 500); + } + + if (result.action === UPDATE_ACTION.NOOP) { + return ddns(`${DDNS_RESPONSE.NOCHG} ${ip}`); + } + + return ddns(`${DDNS_RESPONSE.GOOD} ${ip}`); + } catch { + return ddns(DDNS_RESPONSE.SERVER_ERROR, 500); + } +} diff --git a/src/endpoints/tasks/base.ts b/src/endpoints/tasks/base.ts deleted file mode 100644 index d669508..0000000 --- a/src/endpoints/tasks/base.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod"; - -export const task = z.object({ - id: z.number().int(), - name: z.string(), - slug: z.string(), - description: z.string(), - completed: z.boolean(), - due_date: z.string().datetime(), -}); - -export const TaskModel = { - tableName: "tasks", - primaryKeys: ["id"], - schema: task, - serializer: (obj: Record) => { - return { - ...obj, - completed: Boolean(obj.completed), - }; - }, - serializerObject: task, -}; diff --git a/src/endpoints/tasks/router.ts b/src/endpoints/tasks/router.ts deleted file mode 100644 index 30d79aa..0000000 --- a/src/endpoints/tasks/router.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Hono } from "hono"; -import { fromHono } from "chanfana"; -import { TaskList } from "./taskList"; -import { TaskCreate } from "./taskCreate"; -import { TaskRead } from "./taskRead"; -import { TaskUpdate } from "./taskUpdate"; -import { TaskDelete } from "./taskDelete"; - -export const tasksRouter = fromHono(new Hono()); - -tasksRouter.get("/", TaskList); -tasksRouter.post("/", TaskCreate); -tasksRouter.get("/:id", TaskRead); -tasksRouter.put("/:id", TaskUpdate); -tasksRouter.delete("/:id", TaskDelete); diff --git a/src/endpoints/tasks/taskCreate.ts b/src/endpoints/tasks/taskCreate.ts deleted file mode 100644 index 59af600..0000000 --- a/src/endpoints/tasks/taskCreate.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { D1CreateEndpoint } from "chanfana"; -import { HandleArgs } from "../../types"; -import { TaskModel } from "./base"; - -export class TaskCreate extends D1CreateEndpoint { - _meta = { - model: TaskModel, - fields: TaskModel.schema.pick({ - // this is purposely missing the id, because users shouldn't be able to define it - name: true, - slug: true, - description: true, - completed: true, - due_date: true, - }), - }; -} diff --git a/src/endpoints/tasks/taskDelete.ts b/src/endpoints/tasks/taskDelete.ts deleted file mode 100644 index 61a7994..0000000 --- a/src/endpoints/tasks/taskDelete.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { D1DeleteEndpoint } from "chanfana"; -import { HandleArgs } from "../../types"; -import { TaskModel } from "./base"; - -export class TaskDelete extends D1DeleteEndpoint { - _meta = { - model: TaskModel, - }; -} diff --git a/src/endpoints/tasks/taskList.ts b/src/endpoints/tasks/taskList.ts deleted file mode 100644 index 170690a..0000000 --- a/src/endpoints/tasks/taskList.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { D1ListEndpoint } from "chanfana"; -import { HandleArgs } from "../../types"; -import { TaskModel } from "./base"; - -export class TaskList extends D1ListEndpoint { - _meta = { - model: TaskModel, - }; - - searchFields = ["name", "slug", "description"]; - defaultOrderBy = "id DESC"; -} diff --git a/src/endpoints/tasks/taskRead.ts b/src/endpoints/tasks/taskRead.ts deleted file mode 100644 index 7f35239..0000000 --- a/src/endpoints/tasks/taskRead.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { D1ReadEndpoint } from "chanfana"; -import { HandleArgs } from "../../types"; -import { TaskModel } from "./base"; - -export class TaskRead extends D1ReadEndpoint { - _meta = { - model: TaskModel, - }; -} diff --git a/src/endpoints/tasks/taskUpdate.ts b/src/endpoints/tasks/taskUpdate.ts deleted file mode 100644 index 7c8d110..0000000 --- a/src/endpoints/tasks/taskUpdate.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { D1UpdateEndpoint } from "chanfana"; -import { HandleArgs } from "../../types"; -import { TaskModel } from "./base"; - -export class TaskUpdate extends D1UpdateEndpoint { - _meta = { - model: TaskModel, - fields: TaskModel.schema.pick({ - name: true, - slug: true, - description: true, - completed: true, - due_date: true, - }), - }; -} diff --git a/src/endpoints/update.ts b/src/endpoints/update.ts new file mode 100644 index 0000000..d611114 --- /dev/null +++ b/src/endpoints/update.ts @@ -0,0 +1,176 @@ +/** + * JSON API endpoint for programmatic DDNS updates. + * + * This Chanfana OpenAPIRoute provides a `POST /update` endpoint with + * automatic OpenAPI documentation. It is an alternative to the + * Synology-specific GET endpoint, useful for cron scripts or other + * automation that prefers JSON request/response. + */ + +import type { DdnsEnv, AppContext } from "../types"; +import { UPDATE_ACTION } from "../types"; +import { ApiException, contentJson, InputValidationException, OpenAPIRoute } from "chanfana"; +import { z } from "zod"; +import { performDdnsUpdates } from "../ddns"; +import { logUpdate } from "../logging"; +import { + getDefaultHostname, + isIpAddress, + IpAddressSchema, + parseAllowedHostnames, + resolveUpdateHostnames, +} from "../validation"; + +const UpdateBody = z.object({ + /** Hostname to update. If omitted, uses the first allowed hostname. */ + hostname: z.string().optional(), + /** + * IP address to set. If omitted, the worker reads `CF-Connecting-IP` + * from the request headers (the caller's public IP as seen by Cloudflare). + */ + ip: IpAddressSchema.optional(), +}); + +const UpdateResponse = z.object({ + ok: z.boolean(), + action: z.string(), + hostname: z.string(), + record_type: z.string(), + ip: z.string(), + record_id: z.string().optional(), + results: z.array( + z.object({ + hostname: z.string(), + action: z.string(), + record_type: z.string(), + ip: z.string(), + record_id: z.string().optional(), + error: z.string().optional(), + }), + ), +}); + +const ErrorResponse = z.object({ + success: z.boolean(), + errors: z.array(z.object({ code: z.number(), message: z.string() })), +}); + +export class UpdateEndpoint extends OpenAPIRoute { + schema = { + tags: ["DDNS"], + summary: "Update a DNS record with the caller's current IP", + description: + "Authenticate with X-DDNS-Secret header. Optionally provide hostname " + + "and ip in the JSON body; omitted values are inferred from config and " + + "the request's CF-Connecting-IP header.", + request: { + body: { + content: { + "application/json": { + schema: UpdateBody, + }, + }, + }, + headers: z.object({ + "X-DDNS-Secret": z.string().describe("Shared secret for authentication"), + }), + }, + responses: { + "200": { + description: "DNS record update result", + ...contentJson(UpdateResponse), + }, + "401": { + description: "Authentication failed", + ...contentJson(ErrorResponse), + }, + "400": { + description: "Bad request (missing IP or invalid hostname)", + ...contentJson(ErrorResponse), + }, + }, + }; + + async handle(c: AppContext) { + const env: DdnsEnv = c.env; + + // Authenticate via header. + const secret = c.req.header("X-DDNS-Secret"); + if (!secret || secret !== env.DDNS_SHARED_SECRET) { + const err = new ApiException("Unauthorized"); + err.status = 401; + throw err; + } + + // Parse body (Chanfana validates against the schema above). + const data = await this.getValidatedData(); + const body = data.body as z.infer; + + // Resolve hostname. + const allowed = parseAllowedHostnames(env.DDNS_ALLOWED_HOSTNAMES); + const hostname = (body.hostname ?? getDefaultHostname(allowed) ?? "").trim().toLowerCase(); + const targetHostnames = resolveUpdateHostnames(hostname, allowed); + + if (!hostname || targetHostnames.length === 0) { + throw new InputValidationException("Hostname not in allowed list"); + } + + // Resolve IP: prefer body (already validated by schema), fall back to CF-Connecting-IP. + const ip = body.ip ?? c.req.header("CF-Connecting-IP") ?? ""; + + if (!ip || !isIpAddress(ip)) { + throw new InputValidationException("No valid IP address available"); + } + + const result = await performDdnsUpdates(env, targetHostnames, ip); + + // Log (fire-and-forget). + c.executionCtx.waitUntil( + Promise.all( + result.results.map((targetResult) => + logUpdate(env.DB, { + hostname: targetResult.hostname, + record_type: targetResult.record_type, + ip: targetResult.ip, + action: targetResult.action, + error_message: + targetResult.action === UPDATE_ACTION.ERROR ? targetResult.message : null, + source: "api", + }), + ), + ), + ); + + if (result.action === UPDATE_ACTION.ERROR) { + const failedResults = result.results.filter( + ( + targetResult, + ): targetResult is Extract<(typeof result.results)[number], { action: "error" }> => + targetResult.action === UPDATE_ACTION.ERROR, + ); + const message = + failedResults.length === 1 + ? `${failedResults[0].hostname}: ${failedResults[0].message}` + : `Failed to update ${failedResults.length} DNS records`; + throw new ApiException(message); + } + + return c.json({ + ok: true, + action: result.action, + hostname, + record_type: result.record_type, + ip: result.ip, + record_id: result.record_id, + results: result.results.map((targetResult) => ({ + hostname: targetResult.hostname, + action: targetResult.action, + record_type: targetResult.record_type, + ip: targetResult.ip, + record_id: "record_id" in targetResult ? targetResult.record_id : undefined, + error: + targetResult.action === UPDATE_ACTION.ERROR ? targetResult.message : undefined, + })), + }); + } +} diff --git a/src/index.ts b/src/index.ts index 5b29fd6..59bdfea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,50 +1,77 @@ +import type { DdnsEnv } from "./types"; + import { ApiException, fromHono } from "chanfana"; import { Hono } from "hono"; -import { tasksRouter } from "./endpoints/tasks/router"; -import { ContentfulStatusCode } from "hono/utils/http-status"; -import { DummyEndpoint } from "./endpoints/dummyEndpoint"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +import { HealthEndpoint } from "./endpoints/health"; +import { handleSynologyUpdate } from "./endpoints/synology"; +import { UpdateEndpoint } from "./endpoints/update"; +import { cleanupLogs } from "./logging"; + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- -// Start a Hono app -const app = new Hono<{ Bindings: Env }>(); +const app = new Hono<{ Bindings: DdnsEnv }>(); app.onError((err, c) => { if (err instanceof ApiException) { - // If it's a Chanfana ApiException, let Chanfana handle the response return c.json( { success: false, errors: err.buildResponse() }, err.status as ContentfulStatusCode, ); } - console.error("Global error handler caught:", err); // Log the error if it's not known + if (err instanceof HTTPException) { + return err.getResponse(); + } - // For other errors, return a generic 500 response - return c.json( - { - success: false, - errors: [{ code: 7000, message: "Internal Server Error" }], - }, - 500, - ); + console.error("Unhandled error:", err); + return c.json({ success: false, errors: [{ code: 7000, message: "Internal Server Error" }] }, 500); }); -// Setup OpenAPI registry +// Plain Hono route (DynDNS2 text responses, not suited for OpenAPI). +app.get("/nic/update", handleSynologyUpdate); + +// Chanfana OpenAPI routes (JSON, auto-documented). const openapi = fromHono(app, { docs_url: "/", schema: { info: { - title: "My Awesome API", - version: "2.0.0", - description: "This is the documentation for my awesome API.", + title: "Cloudflare DDNS", + version: "1.0.0", + description: + "A Cloudflare Worker that acts as a DDNS provider. " + + "Synology NAS devices (and other clients) call this service " + + "to keep DNS records in sync with a changing public IP address.", }, }, }); -// Register Tasks Sub router -openapi.route("/tasks", tasksRouter); +openapi.get("/health", HealthEndpoint); +openapi.post("/update", UpdateEndpoint); + +// --------------------------------------------------------------------------- +// Scheduled handler: prune old D1 log rows +// --------------------------------------------------------------------------- -// Register other endpoints -openapi.post("/dummy/:slug", DummyEndpoint); +export default { + fetch: app.fetch, -// Export the Hono app -export default app; + async scheduled( + _event: ScheduledEvent, + env: DdnsEnv, + ctx: ExecutionContext, + ): Promise { + const retentionDays = Number.parseInt(env.DDNS_LOG_RETENTION_DAYS ?? "30", 10) || 30; + ctx.waitUntil( + cleanupLogs(env.DB, retentionDays).then((deleted) => { + if (deleted > 0) { + console.log(`Cleaned up ${deleted} DDNS log rows older than ${retentionDays} days`); + } + }), + ); + }, +}; diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..5fe7e51 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,65 @@ +/** + * D1-backed update logging. + * + * Each DDNS update attempt (success or failure) is recorded so operators + * can audit what happened. A scheduled cron job calls `cleanupLogs` to + * prune rows older than the configured retention period, keeping the + * D1 database within free-tier row limits. + */ + +import type { UpdateAction } from "./types"; + +/** Shape of a row in the `ddns_logs` table. */ +export interface DdnsLogEntry { + hostname: string; + record_type: string; + ip: string; + action: UpdateAction; + error_message: string | null; + source: "synology" | "api"; +} + +/** + * Insert a single update log row into D1. + * + * Failures are intentionally swallowed (logged to console) so that a + * D1 hiccup does not prevent the DNS update response from reaching + * the caller. + */ +export async function logUpdate(db: D1Database, entry: DdnsLogEntry): Promise { + try { + await db + .prepare( + `INSERT INTO ddns_logs (hostname, record_type, ip, action, error_message, source) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .bind( + entry.hostname, + entry.record_type, + entry.ip, + entry.action, + entry.error_message, + entry.source, + ) + .run(); + } catch (err) { + console.error("Failed to write DDNS log entry:", err); + } +} + +/** + * Delete log rows older than `retentionDays` days. + * Returns the number of deleted rows, or 0 if the cleanup fails. + */ +export async function cleanupLogs(db: D1Database, retentionDays: number): Promise { + try { + const result = await db + .prepare(`DELETE FROM ddns_logs WHERE created_at < datetime('now', ? || ' days')`) + .bind(-retentionDays) + .run(); + return result.meta.changes ?? 0; + } catch (err) { + console.error("Failed to clean up DDNS logs:", err); + return 0; + } +} diff --git a/src/types.ts b/src/types.ts index e06f914..a92f74f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,81 @@ import type { Context } from "hono"; -export type AppContext = Context<{ Bindings: Env }>; -export type HandleArgs = [AppContext]; +/** + * Worker environment bindings. + * + * Secrets (`CF_API_TOKEN`, `CF_ZONE_ID`, `DDNS_SHARED_SECRET`) are set via + * `wrangler secret put`. Non-secret defaults live in `wrangler.jsonc` `[vars]`. + * + * The generated `worker-configuration.d.ts` provides the global `Env` with + * the D1 binding. This interface extends it with the DDNS-specific variables + * so handler code gets full type coverage. + */ +export interface DdnsEnv extends Env { + /** Cloudflare API token with DNS:Edit permission for the target zone. */ + CF_API_TOKEN: string; + /** Cloudflare zone ID that owns the hostnames being updated. */ + CF_ZONE_ID: string; + /** Shared secret that callers must provide to authenticate updates. */ + DDNS_SHARED_SECRET: string; + /** + * Comma-separated list of hostnames this worker is allowed to update. + * Prevents a leaked secret from modifying arbitrary zone records. + * + * @example "nas.example.com,home.example.com" + */ + DDNS_ALLOWED_HOSTNAMES: string; + /** + * Whether created/updated records should be proxied through Cloudflare. + * `"true"` or `"false"`. Defaults to `"false"` (DNS-only) because most + * NAS use cases need direct IP access on non-standard ports. + */ + DDNS_PROXIED: string; + /** + * DNS record TTL in seconds. `"1"` means automatic (Cloudflare chooses). + * Valid range is 60-86400 or 1 for auto. Defaults to `"1"`. + */ + DDNS_TTL: string; + /** + * How many days of update logs to keep in D1 before the scheduled + * cleanup job deletes them. Defaults to `"30"`. + */ + DDNS_LOG_RETENTION_DAYS: string; +} + +/** Hono context typed with the DDNS environment bindings. */ +export type AppContext = Context<{ Bindings: DdnsEnv }>; + +/** + * DynDNS-style response codes returned by the Synology-compatible endpoint. + * + * Synology DSM parses the response body for one of these keywords to decide + * whether the update succeeded. The codes follow the DynDNS2 protocol that + * most consumer DDNS providers and Synology custom providers expect. + */ +export const DDNS_RESPONSE = { + /** Record created or updated successfully. Body: `good ` */ + GOOD: "good", + /** Record already has the correct value. Body: `nochg ` */ + NOCHG: "nochg", + /** Authentication failed (bad secret or username). */ + BADAUTH: "badauth", + /** Hostname is not in the allowed list. */ + NOHOST: "nohost", + /** Server-side error. */ + SERVER_ERROR: "911", +} as const; + +export type DdnsResponse = (typeof DDNS_RESPONSE)[keyof typeof DDNS_RESPONSE]; + +/** + * Actions recorded in the `ddns_logs` D1 table. + * Each update attempt produces exactly one of these outcomes. + */ +export const UPDATE_ACTION = { + CREATED: "created", + UPDATED: "updated", + NOOP: "noop", + ERROR: "error", +} as const; + +export type UpdateAction = (typeof UPDATE_ACTION)[keyof typeof UPDATE_ACTION]; diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..6330e78 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,119 @@ +/** + * Pure validation and config-parsing helpers. + * + * IP validation uses Zod v4's built-in `z.ipv4()` / `z.ipv6()` validators. + * Config-parsing helpers remain hand-written because their clamping/fallback + * semantics don't map to a simpler Zod form. + */ + +import { z } from "zod"; + +/** Zod schema for a valid IPv4 address string. */ +export const Ipv4Schema = z.ipv4(); + +/** Zod schema for a valid IPv6 address string. */ +export const Ipv6Schema = z.ipv6(); + +/** Zod schema accepting either a valid IPv4 or IPv6 address. */ +export const IpAddressSchema = z.union([z.ipv4(), z.ipv6()]); + +/** Returns `true` if `value` is a valid IPv4 address. */ +export function isIpv4(value: string): boolean { + return Ipv4Schema.safeParse(value).success; +} + +/** Returns `true` if `value` is a valid IPv6 address. */ +export function isIpv6(value: string): boolean { + return Ipv6Schema.safeParse(value).success; +} + +/** Returns `true` for either a valid IPv4 or IPv6 address string. */ +export function isIpAddress(value: string): boolean { + return IpAddressSchema.safeParse(value).success; +} + +/** + * Determine which DNS record type to use for an IP address. + * IPv4 addresses map to `A` records, IPv6 to `AAAA`. + */ +export function detectRecordType(ip: string): "A" | "AAAA" { + return ip.includes(":") ? "AAAA" : "A"; +} + +/** + * Parse a string boolean from an environment variable. + * Recognizes `"true"` and `"false"` (case-insensitive, trimmed). + * Returns `fallback` for any unrecognized or missing value. + */ +export function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (!value) return fallback; + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + return fallback; +} + +/** + * Parse a TTL value from an environment variable. + * + * Cloudflare DNS accepts TTL `1` (automatic) or a value in 60-86400 seconds. + * Returns `1` for missing, non-numeric, or out-of-range input. + */ +export function parseTtl(value: string | undefined): number { + if (!value) return 1; + const ttl = Number.parseInt(value, 10); + if (!Number.isFinite(ttl)) return 1; + if (ttl === 1) return 1; + if (ttl < 60) return 60; + if (ttl > 86400) return 86400; + return ttl; +} + +/** + * Split a comma-separated hostname list into a normalized array. + * Each entry is trimmed and lowercased. Empty entries are dropped. + */ +export function parseAllowedHostnames(value: string | undefined): string[] { + if (!value) return []; + return value + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter((h) => h.length > 0); +} + +/** + * Pick the default hostname for requests that omit one. + * + * Prefer an exact hostname over a wildcard companion so a config like + * `nas.example.com,*.nas.example.com` defaults to the exact record. + */ +export function getDefaultHostname(allowedHostnames: string[]): string | undefined { + return allowedHostnames.find((hostname) => !hostname.startsWith("*.")) ?? allowedHostnames[0]; +} + +/** + * Resolve the concrete DNS records that should be updated for a request. + * + * A request for an exact allowed hostname also updates its explicit wildcard + * companion when `*.${hostname}` is present in the allowlist. + */ +export function resolveUpdateHostnames( + hostname: string, + allowedHostnames: string[], +): string[] { + const normalizedHostname = hostname.trim().toLowerCase(); + if (!normalizedHostname) return []; + + const allowedSet = new Set(allowedHostnames); + if (!allowedSet.has(normalizedHostname)) return []; + + const targets = [normalizedHostname]; + if (!normalizedHostname.startsWith("*.")) { + const wildcardCompanion = `*.${normalizedHostname}`; + if (allowedSet.has(wildcardCompanion)) { + targets.push(wildcardCompanion); + } + } + + return targets; +} diff --git a/tests/bindings.d.ts b/tests/bindings.d.ts index 3acc392..85ca40d 100644 --- a/tests/bindings.d.ts +++ b/tests/bindings.d.ts @@ -1,7 +1,7 @@ import type { D1Migration } from "cloudflare:test"; -import type { Env as AppEnv } from "../src/bindings"; +import type { DdnsEnv } from "../src/types"; -export type Env = AppEnv & { +export type Env = DdnsEnv & { MIGRATIONS: D1Migration[]; }; diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..f923670 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,141 @@ +/** + * Shared test utilities for mocking the Cloudflare DNS API. + * + * The mock intercepts outbound `fetch()` calls to `api.cloudflare.com` + * and maintains an in-memory record store so tests can exercise the + * worker's DNS update logic without real API calls. + */ + +import type { DnsRecord } from "../src/cloudflare-api"; + +const CF_API_BASE = "https://api.cloudflare.com/client/v4"; + +interface MockDnsState { + records: Map; + nextId: number; +} + +/** + * Create an in-memory mock of the Cloudflare DNS Records API. + * + * Call `install()` in `beforeEach` and `restore()` in `afterEach`. + * Use `seedRecord()` to set up existing records for a test case. + */ +export function createMockDnsApi() { + const state: MockDnsState = { records: new Map(), nextId: 1 }; + const originalFetch = globalThis.fetch; + + function seedRecord(record: DnsRecord) { + state.records.set(record.id, record); + } + + function reset() { + state.records.clear(); + state.nextId = 1; + } + + const mockFetch = async ( + input: string | Request | URL, + init?: RequestInit, + ): Promise => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + const method = + init?.method?.toUpperCase() ?? + (typeof input === "object" && "method" in input + ? (input as Request).method.toUpperCase() + : "GET"); + + // Pass through anything that is not directed at the Cloudflare API. + if (!url.startsWith(CF_API_BASE)) { + return originalFetch(input, init as RequestInit); + } + + // GET /zones/{zone_id}/dns_records?type=...&name.exact=... + if (method === "GET" && url.includes("/dns_records")) { + const parsed = new URL(url); + const type = parsed.searchParams.get("type"); + const name = parsed.searchParams.get("name.exact") ?? parsed.searchParams.get("name"); + + const results = [...state.records.values()].filter( + (r) => r.type === type && r.name === name, + ); + + return Response.json({ + success: true, + result: results, + result_info: { page: 1, per_page: 100, count: results.length, total_count: results.length }, + errors: [], + }); + } + + // POST /zones/{zone_id}/dns_records + if (method === "POST" && url.endsWith("/dns_records")) { + const body = JSON.parse(init?.body as string); + const id = `rec-${state.nextId++}`; + const record: DnsRecord = { id, ...body }; + state.records.set(id, record); + return Response.json({ success: true, result: record, errors: [] }); + } + + // PATCH /zones/{zone_id}/dns_records/{id} + // PUT /zones/{zone_id}/dns_records/{id} + if ((method === "PATCH" || method === "PUT") && url.includes("/dns_records/")) { + const id = url.split("/dns_records/")[1].split("?")[0]; + const body = JSON.parse(init?.body as string); + const existing = state.records.get(id); + if (!existing) { + return Response.json( + { + success: false, + result: null, + errors: [{ code: 81044, message: "Record not found" }], + }, + { status: 404 }, + ); + } + const updated: DnsRecord = { ...existing, ...body }; + state.records.set(id, updated); + return Response.json({ success: true, result: updated, errors: [] }); + } + + return Response.json( + { success: false, errors: [{ code: 0, message: "Unknown API route" }] }, + { status: 404 }, + ); + }; + + return { + state, + seedRecord, + reset, + install: () => { + globalThis.fetch = mockFetch as typeof fetch; + }, + restore: () => { + globalThis.fetch = originalFetch; + }, + }; +} + +/** Build a Synology-style update URL with sensible defaults. */ +export function makeSynologyUrl( + overrides: { + hostname?: string; + myip?: string; + username?: string; + password?: string; + } = {}, +): string { + const params = new URLSearchParams({ + hostname: overrides.hostname ?? "nas.example.com", + myip: overrides.myip ?? "1.2.3.4", + username: overrides.username ?? "ddns", + password: overrides.password ?? "test-secret", + }); + return `http://localhost/nic/update?${params}`; +} diff --git a/tests/integration/dummyEndpoint.test.ts b/tests/integration/dummyEndpoint.test.ts deleted file mode 100644 index 69afe44..0000000 --- a/tests/integration/dummyEndpoint.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { SELF } from "cloudflare:test"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Dummy API Integration Tests", () => { - beforeEach(async () => { - vi.clearAllMocks(); - }); - - describe("POST /dummy/{slug}", () => { - it("should return the log details", async () => { - const slug = "test-slug"; - const requestBody = { name: "Test Name" }; - const response = await SELF.fetch(`http://local.test/dummy/${slug}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), - }); - const body = await response.json<{ success: boolean; result: any }>(); - - expect(response.status).toBe(200); - expect(body.success).toBe(true); - expect(body.result.slug).toBe(slug); - expect(body.result.name).toBe(requestBody.name); - expect(body.result).toHaveProperty("msg"); - }); - }); -}); diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts new file mode 100644 index 0000000..f4906c2 --- /dev/null +++ b/tests/integration/health.test.ts @@ -0,0 +1,10 @@ +import { SELF } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +describe("GET /health", () => { + it("returns ok: true", async () => { + const response = await SELF.fetch("http://localhost/health"); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); diff --git a/tests/integration/synology.test.ts b/tests/integration/synology.test.ts new file mode 100644 index 0000000..22c4cc7 --- /dev/null +++ b/tests/integration/synology.test.ts @@ -0,0 +1,186 @@ +import { SELF } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createMockDnsApi, makeSynologyUrl } from "../helpers"; + +const dns = createMockDnsApi(); +const DEFAULT_ALLOWED_HOSTNAMES = "nas.example.com,home.example.com"; + +function setAllowedHostnames(value: string) { + Object.assign(env, { DDNS_ALLOWED_HOSTNAMES: value }); +} + +beforeEach(() => { + setAllowedHostnames(DEFAULT_ALLOWED_HOSTNAMES); + dns.reset(); + dns.install(); +}); + + +beforeEach(async () => { + await env.DB.prepare("DELETE FROM ddns_logs").run(); +}); + +afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + await env.DB.prepare("DELETE FROM ddns_logs").run(); + dns.restore(); +}); + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +describe("GET /nic/update — authentication", () => { + it("returns badauth when password is missing", async () => { + const response = await SELF.fetch(makeSynologyUrl({ password: "" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("badauth"); + }); + + it("returns badauth when password is wrong", async () => { + const response = await SELF.fetch(makeSynologyUrl({ password: "wrong-secret" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("badauth"); + }); +}); + +// --------------------------------------------------------------------------- +// Hostname validation +// --------------------------------------------------------------------------- + +describe("GET /nic/update — hostname validation", () => { + it("returns nohost when hostname is missing", async () => { + const response = await SELF.fetch(makeSynologyUrl({ hostname: "" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("nohost"); + }); + + it("returns nohost when hostname is not in the allowed list", async () => { + const response = await SELF.fetch(makeSynologyUrl({ hostname: "evil.example.com" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("nohost"); + }); +}); + +// --------------------------------------------------------------------------- +// DNS record creation +// --------------------------------------------------------------------------- + +describe("GET /nic/update — record creation", () => { + it("creates a new A record and returns good", async () => { + const response = await SELF.fetch(makeSynologyUrl({ myip: "203.0.113.1" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("good 203.0.113.1"); + + // Verify the record was created in the mock store. + const record = [...dns.state.records.values()].find( + (r) => r.name === "nas.example.com", + ); + expect(record).toBeDefined(); + expect(record!.content).toBe("203.0.113.1"); + expect(record!.type).toBe("A"); + }); + + it("creates an AAAA record for IPv6 addresses", async () => { + const response = await SELF.fetch(makeSynologyUrl({ myip: "2001:db8::1" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("good 2001:db8::1"); + + const record = [...dns.state.records.values()].find( + (r) => r.name === "nas.example.com" && r.type === "AAAA", + ); + expect(record).toBeDefined(); + expect(record!.content).toBe("2001:db8::1"); + }); + + it("updates an explicit wildcard companion record when configured", async () => { + setAllowedHostnames("nas.example.com,*.nas.example.com,home.example.com"); + + const response = await SELF.fetch(makeSynologyUrl({ myip: "203.0.113.10" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("good 203.0.113.10"); + + expect([...dns.state.records.values()].map((record) => record.name).sort()).toEqual([ + "*.nas.example.com", + "nas.example.com", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// DNS record update +// --------------------------------------------------------------------------- + +describe("GET /nic/update — record updates", () => { + it("returns nochg when IP already matches", async () => { + dns.seedRecord({ + id: "existing-1", + type: "A", + name: "nas.example.com", + content: "203.0.113.1", + proxied: false, + ttl: 1, + }); + + const response = await SELF.fetch(makeSynologyUrl({ myip: "203.0.113.1" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("nochg 203.0.113.1"); + }); + + it("updates existing record when IP changes", async () => { + dns.seedRecord({ + id: "existing-2", + type: "A", + name: "nas.example.com", + content: "203.0.113.1", + proxied: false, + ttl: 1, + }); + + const response = await SELF.fetch(makeSynologyUrl({ myip: "203.0.113.99" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("good 203.0.113.99"); + + const updated = dns.state.records.get("existing-2"); + expect(updated!.content).toBe("203.0.113.99"); + }); +}); + +// --------------------------------------------------------------------------- +// D1 logging +// --------------------------------------------------------------------------- + +describe("GET /nic/update — D1 logging", () => { + it("writes a log row on successful update", async () => { + await SELF.fetch(makeSynologyUrl({ myip: "198.51.100.1" })); + + // Give the waitUntil promise a tick to settle. + await new Promise((r) => setTimeout(r, 50)); + + const { results } = await env.DB.prepare( + "SELECT * FROM ddns_logs ORDER BY id DESC LIMIT 1", + ).all(); + + expect(results.length).toBe(1); + expect(results[0].hostname).toBe("nas.example.com"); + expect(results[0].ip).toBe("198.51.100.1"); + expect(results[0].action).toBe("created"); + expect(results[0].source).toBe("synology"); + }); +}); + +// --------------------------------------------------------------------------- +// Second allowed hostname +// --------------------------------------------------------------------------- + +describe("GET /nic/update — multiple allowed hostnames", () => { + it("accepts the second hostname in the allowed list", async () => { + const response = await SELF.fetch( + makeSynologyUrl({ hostname: "home.example.com", myip: "10.0.0.1" }), + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("good 10.0.0.1"); + }); +}); diff --git a/tests/integration/tasks.test.ts b/tests/integration/tasks.test.ts deleted file mode 100644 index d83e5f5..0000000 --- a/tests/integration/tasks.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { SELF } from "cloudflare:test"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -// Helper function to create a task and return its ID -async function createTask(taskData: any) { - const response = await SELF.fetch(`http://local.test/tasks`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(taskData), - }); - const body = await response.json<{ - success: boolean; - result: { id: number }; - }>(); - return body.result.id; -} - -describe("Task API Integration Tests", () => { - beforeEach(async () => { - // This is a good place to clear any test data if your test runner doesn't do it automatically. - // Since the prompt mentions rows are deleted after each test, we can rely on that. - vi.clearAllMocks(); - }); - - // Tests for GET /tasks - describe("GET /tasks", () => { - it("should get an empty list of tasks", async () => { - const response = await SELF.fetch(`http://local.test/tasks`); - const body = await response.json<{ success: boolean; result: any[] }>(); - - expect(response.status).toBe(200); - expect(body.success).toBe(true); - expect(body.result).toEqual([]); - }); - - it("should get a list with one task", async () => { - await createTask({ - name: "Test Task", - slug: "test-task", - description: "A task for testing", - completed: false, - due_date: "2025-01-01T00:00:00.000Z", - }); - - const response = await SELF.fetch(`http://local.test/tasks`); - const body = await response.json<{ success: boolean; result: any[] }>(); - - expect(response.status).toBe(200); - expect(body.success).toBe(true); - expect(body.result.length).toBe(1); - expect(body.result[0]).toEqual( - expect.objectContaining({ - name: "Test Task", - slug: "test-task", - }), - ); - }); - }); - - // Tests for POST /tasks - describe("POST /tasks", () => { - it("should create a new task successfully", async () => { - const taskData = { - name: "New Task", - slug: "new-task", - description: "A brand new task", - completed: false, - due_date: "2025-12-31T23:59:59.000Z", - }; - const response = await SELF.fetch(`http://local.test/tasks`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(taskData), - }); - - const body = await response.json<{ success: boolean; result: any }>(); - - expect(response.status).toBe(201); - expect(body.success).toBe(true); - expect(body.result).toEqual( - expect.objectContaining({ - id: expect.any(Number), - ...taskData, - }), - ); - }); - - it("should return a 400 error for invalid input", async () => { - const invalidTaskData = { - // Missing required fields 'name', 'slug', etc. - description: "This is an invalid task", - }; - const response = await SELF.fetch(`http://local.test/tasks`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(invalidTaskData), - }); - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body.success).toBe(false); - expect(body.errors).toBeInstanceOf(Array); - }); - }); - - // Tests for GET /tasks/{id} - describe("GET /tasks/{id}", () => { - it("should get a single task by its ID", async () => { - const taskData = { - name: "Specific Task", - slug: "specific-task", - description: "A task to be fetched by ID", - completed: false, - due_date: "2025-06-01T12:00:00.000Z", - }; - const taskId = await createTask(taskData); - - const response = await SELF.fetch(`http://local.test/tasks/${taskId}`); - const body = await response.json<{ success: boolean; result: any }>(); - - expect(response.status).toBe(200); - expect(body.success).toBe(true); - expect(body.result).toEqual( - expect.objectContaining({ - id: taskId, - ...taskData, - }), - ); - }); - - it("should return a 404 error if task is not found", async () => { - const nonExistentId = 9999; - const response = await SELF.fetch( - `http://local.test/tasks/${nonExistentId}`, - ); - const body = await response.json(); - - expect(response.status).toBe(404); - expect(body.success).toBe(false); - expect(body.errors[0].message).toBe("Not Found"); - }); - }); - - // Tests for PUT /tasks/{id} - describe("PUT /tasks/{id}", () => { - it("should update a task successfully", async () => { - const taskData = { - name: "Task to Update", - slug: "task-to-update", - description: "This task will be updated", - completed: false, - due_date: "2025-07-01T00:00:00.000Z", - }; - const taskId = await createTask(taskData); - - const updatedData = { - name: "Updated Task", - slug: "updated-task", - description: "This task has been updated", - completed: true, - due_date: "2025-07-15T10:00:00.000Z", - }; - - const response = await SELF.fetch(`http://local.test/tasks/${taskId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updatedData), - }); - const body = await response.json<{ success: boolean; result: any }>(); - - expect(response.status).toBe(200); - expect(body.success).toBe(true); - expect(body.result).toEqual( - expect.objectContaining({ - id: taskId, - ...updatedData, - }), - ); - }); - - it("should return 404 when trying to update a non-existent task", async () => { - const nonExistentId = 9999; - const updatedData = { - name: "Updated Task", - slug: "updated-task", - description: "This task has been updated", - completed: true, - due_date: "2025-07-15T10:00:00.000Z", - }; - const response = await SELF.fetch( - `http://local.test/tasks/${nonExistentId}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updatedData), - }, - ); - - expect(response.status).toBe(404); - }); - - it("should return 400 for invalid update data", async () => { - const taskId = await createTask({ - name: "Task", - slug: "task", - description: "...", - completed: false, - due_date: "2025-01-01T00:00:00.000Z", - }); - const invalidUpdateData = { name: "" }; // Invalid name - const response = await SELF.fetch(`http://local.test/tasks/${taskId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(invalidUpdateData), - }); - - expect(response.status).toBe(400); - }); - }); - - // Tests for DELETE /tasks/{id} - describe("DELETE /tasks/{id}", () => { - it("should delete a task successfully", async () => { - const taskData = { - name: "Task to Delete", - slug: "task-to-delete", - description: "This task will be deleted", - completed: false, - due_date: "2025-08-01T00:00:00.000Z", - }; - const taskId = await createTask(taskData); - - const deleteResponse = await SELF.fetch( - `http://local.test/tasks/${taskId}`, - { - method: "DELETE", - }, - ); - const deleteBody = await deleteResponse.json<{ - success: boolean; - result: any; - }>(); - - expect(deleteResponse.status).toBe(200); - expect(deleteBody.success).toBe(true); - expect(deleteBody.result.id).toBe(taskId); - - // Verify the task is actually deleted - const getResponse = await SELF.fetch(`http://local.test/tasks/${taskId}`); - expect(getResponse.status).toBe(404); - }); - - it("should return 404 when trying to delete a non-existent task", async () => { - const nonExistentId = 9999; - const response = await SELF.fetch( - `http://local.test/tasks/${nonExistentId}`, - { - method: "DELETE", - }, - ); - const body = await response.json(); - - expect(response.status).toBe(404); - expect(body.success).toBe(false); - expect(body.errors[0].message).toBe("Not Found"); - }); - }); -}); diff --git a/tests/integration/update.test.ts b/tests/integration/update.test.ts new file mode 100644 index 0000000..ed7db84 --- /dev/null +++ b/tests/integration/update.test.ts @@ -0,0 +1,251 @@ +import { SELF } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createMockDnsApi } from "../helpers"; + +const dns = createMockDnsApi(); +const DEFAULT_ALLOWED_HOSTNAMES = "nas.example.com,home.example.com"; + +function setAllowedHostnames(value: string) { + Object.assign(env, { DDNS_ALLOWED_HOSTNAMES: value }); +} + +beforeEach(() => { + setAllowedHostnames(DEFAULT_ALLOWED_HOSTNAMES); + dns.reset(); + dns.install(); +}); + + +beforeEach(async () => { + await env.DB.prepare("DELETE FROM ddns_logs").run(); +}); + +afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + await env.DB.prepare("DELETE FROM ddns_logs").run(); + dns.restore(); +}); + +/** Build a POST /update request with sensible defaults. */ +function makeUpdateRequest( + overrides: { + secret?: string | null; + hostname?: string; + ip?: string; + cfConnectingIp?: string; + } = {}, +): Request { + const headers: Record = { + "Content-Type": "application/json", + }; + if (overrides.secret !== null) { + headers["X-DDNS-Secret"] = overrides.secret ?? "test-secret"; + } + if (overrides.cfConnectingIp) { + headers["CF-Connecting-IP"] = overrides.cfConnectingIp; + } + + const body: Record = {}; + if (overrides.hostname !== undefined) body.hostname = overrides.hostname; + if (overrides.ip !== undefined) body.ip = overrides.ip; + + return new Request("http://localhost/update", { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +describe("POST /update — authentication", () => { + it("returns 401 when secret header is missing", async () => { + const response = await SELF.fetch(makeUpdateRequest({ secret: null, ip: "1.2.3.4" })); + expect(response.status).toBe(401); + const json = await response.json<{ success: boolean }>(); + expect(json.success).toBe(false); + }); + + it("returns 401 when secret is wrong", async () => { + const response = await SELF.fetch(makeUpdateRequest({ secret: "wrong", ip: "1.2.3.4" })); + expect(response.status).toBe(401); + }); +}); + +// --------------------------------------------------------------------------- +// Hostname validation +// --------------------------------------------------------------------------- + +describe("POST /update — hostname validation", () => { + it("returns 400 for disallowed hostname", async () => { + const response = await SELF.fetch( + makeUpdateRequest({ hostname: "evil.example.com", ip: "1.2.3.4" }), + ); + expect(response.status).toBe(400); + const json = await response.json<{ errors: Array<{ message: string }> }>(); + expect(json.errors[0].message).toContain("Hostname"); + }); + + it("defaults to the first allowed hostname when omitted", async () => { + const response = await SELF.fetch(makeUpdateRequest({ ip: "198.51.100.5" })); + expect(response.status).toBe(200); + const json = await response.json<{ hostname: string }>(); + expect(json.hostname).toBe("nas.example.com"); + }); +}); + +// --------------------------------------------------------------------------- +// IP resolution +// --------------------------------------------------------------------------- + +describe("POST /update — IP resolution", () => { + it("returns 400 when no IP is available", async () => { + const response = await SELF.fetch(makeUpdateRequest({})); + expect(response.status).toBe(400); + const json = await response.json<{ errors: Array<{ message: string }> }>(); + expect(json.errors[0].message).toContain("IP"); + }); + + it("uses CF-Connecting-IP when body IP is omitted", async () => { + const response = await SELF.fetch( + makeUpdateRequest({ cfConnectingIp: "198.51.100.10" }), + ); + expect(response.status).toBe(200); + const json = await response.json<{ ip: string }>(); + expect(json.ip).toBe("198.51.100.10"); + }); + + it("prefers body IP over CF-Connecting-IP", async () => { + const response = await SELF.fetch( + makeUpdateRequest({ ip: "10.0.0.1", cfConnectingIp: "10.0.0.2" }), + ); + expect(response.status).toBe(200); + const json = await response.json<{ ip: string }>(); + expect(json.ip).toBe("10.0.0.1"); + }); +}); + +// --------------------------------------------------------------------------- +// DNS record lifecycle +// --------------------------------------------------------------------------- + +describe("POST /update — DNS updates", () => { + it("creates a new record", async () => { + const response = await SELF.fetch(makeUpdateRequest({ ip: "203.0.113.1" })); + expect(response.status).toBe(200); + const json = await response.json<{ + ok: boolean; + action: string; + record_type: string; + }>(); + expect(json.ok).toBe(true); + expect(json.action).toBe("created"); + expect(json.record_type).toBe("A"); + }); + + it("returns noop when record is unchanged", async () => { + dns.seedRecord({ + id: "rec-existing", + type: "A", + name: "nas.example.com", + content: "203.0.113.1", + proxied: false, + ttl: 1, + }); + + const response = await SELF.fetch(makeUpdateRequest({ ip: "203.0.113.1" })); + expect(response.status).toBe(200); + const json = await response.json<{ action: string }>(); + expect(json.action).toBe("noop"); + }); + + it("updates existing record when IP changes", async () => { + dns.seedRecord({ + id: "rec-existing", + type: "A", + name: "nas.example.com", + content: "203.0.113.1", + proxied: false, + ttl: 1, + }); + + const response = await SELF.fetch(makeUpdateRequest({ ip: "203.0.113.99" })); + expect(response.status).toBe(200); + const json = await response.json<{ action: string; ip: string }>(); + expect(json.action).toBe("updated"); + expect(json.ip).toBe("203.0.113.99"); + }); + + it("handles IPv6 addresses", async () => { + const response = await SELF.fetch(makeUpdateRequest({ ip: "2001:db8::1" })); + expect(response.status).toBe(200); + const json = await response.json<{ record_type: string }>(); + expect(json.record_type).toBe("AAAA"); + }); + + it("updates an explicit wildcard companion record when configured", async () => { + setAllowedHostnames("nas.example.com,*.nas.example.com,home.example.com"); + + const response = await SELF.fetch(makeUpdateRequest({ ip: "203.0.113.10" })); + expect(response.status).toBe(200); + const json = await response.json<{ + action: string; + results: Array<{ hostname: string; action: string }>; + }>(); + + expect(json.action).toBe("created"); + expect(json.results).toHaveLength(2); + expect(json.results.map((result: { hostname: string }) => result.hostname)).toEqual([ + "nas.example.com", + "*.nas.example.com", + ]); + expect([...dns.state.records.values()].map((record) => record.name).sort()).toEqual([ + "*.nas.example.com", + "nas.example.com", + ]); + }); +}); + +// --------------------------------------------------------------------------- +// D1 logging +// --------------------------------------------------------------------------- + +describe("POST /update — D1 logging", () => { + it("writes a log row on successful update", async () => { + await SELF.fetch(makeUpdateRequest({ ip: "198.51.100.1" })); + + // Give the waitUntil promise a tick to settle. + await new Promise((r) => setTimeout(r, 50)); + + const { results } = await env.DB.prepare( + "SELECT * FROM ddns_logs ORDER BY id DESC LIMIT 1", + ).all(); + + expect(results.length).toBe(1); + expect(results[0].hostname).toBe("nas.example.com"); + expect(results[0].ip).toBe("198.51.100.1"); + expect(results[0].action).toBe("created"); + expect(results[0].source).toBe("api"); + }); + + it("writes one log row per companion record update", async () => { + setAllowedHostnames("nas.example.com,*.nas.example.com"); + + await SELF.fetch(makeUpdateRequest({ ip: "198.51.100.20" })); + await new Promise((r) => setTimeout(r, 50)); + + const { results } = await env.DB.prepare( + "SELECT hostname, action FROM ddns_logs ORDER BY hostname ASC", + ).all(); + + expect(results).toHaveLength(2); + expect(results.map((row) => row.hostname)).toEqual([ + "*.nas.example.com", + "nas.example.com", + ]); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index c1930b8..d7bf6e9 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -2,7 +2,15 @@ "extends": "../tsconfig.json", "compilerOptions": { "moduleResolution": "bundler", - "types": ["@cloudflare/vitest-pool-workers"] + "types": [ + "@cloudflare/vitest-pool-workers", + "@cloudflare/vitest-pool-workers/types" + ] }, - "include": ["./**/*.ts", "./**/*.mts"] + "include": [ + "./**/*.ts", + "./**/*.mts", + "./**/*.d.ts", + "../worker-configuration.d.ts" + ] } diff --git a/tests/vitest.config.mts b/tests/vitest.config.mts index e3618cb..cd97b67 100644 --- a/tests/vitest.config.mts +++ b/tests/vitest.config.mts @@ -1,31 +1,41 @@ import path from "node:path"; -import { - defineWorkersConfig, - readD1Migrations, -} from "@cloudflare/vitest-pool-workers/config"; +import { fileURLToPath } from "node:url"; -const migrationsPath = path.join(__dirname, "..", "migrations"); -const migrations = await readD1Migrations(migrationsPath); +import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; -export default defineWorkersConfig({ +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ esbuild: { target: "esnext", }, - test: { - setupFiles: ["./tests/apply-migrations.ts"], - poolOptions: { - workers: { - singleWorker: true, + plugins: [ + cloudflareTest(async () => { + const migrationsPath = path.join(__dirname, "..", "migrations"); + const migrations = await readD1Migrations(migrationsPath); + + return { wrangler: { - configPath: "../wrangler.jsonc", + configPath: "./wrangler.jsonc", }, miniflare: { - compatibilityFlags: ["experimental", "nodejs_compat"], bindings: { MIGRATIONS: migrations, + // Test credentials — these only exist inside miniflare. + CF_API_TOKEN: "test-api-token", + CF_ZONE_ID: "test-zone-id", + DDNS_SHARED_SECRET: "test-secret", + DDNS_ALLOWED_HOSTNAMES: "nas.example.com,home.example.com", + DDNS_PROXIED: "false", + DDNS_TTL: "1", + DDNS_LOG_RETENTION_DAYS: "30", }, }, - }, - }, + }; + }), + ], + test: { + setupFiles: ["./tests/apply-migrations.ts"], }, }); diff --git a/wrangler.jsonc b/wrangler.jsonc index a96fede..555b8bb 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,8 @@ { - "compatibility_date": "2025-10-08", + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-ddns", "main": "src/index.ts", - "name": "chanfana-openapi-template", + "compatibility_date": "2026-03-31", "upload_source_maps": true, "observability": { "enabled": true @@ -9,8 +10,16 @@ "d1_databases": [ { "binding": "DB", - "database_name": "openapi-template-db", - "database_id": "18d141d5-062d-46a0-a92a-bade3f8c4b00" + "database_name": "cloudflare-ddns-db", + "database_id": "00000000-0000-0000-0000-000000000000" } - ] + ], + "vars": { + "DDNS_PROXIED": "false", + "DDNS_TTL": "1", + "DDNS_LOG_RETENTION_DAYS": "30" + }, + "triggers": { + "crons": ["0 */6 * * *"] + } } -- 2.51.2